PDF To Markdown

Convert a PDF into a markdown document, handling images and latex.

---
format: typebulb/v1
name: PDF To Markdown
---

**code.tsx**

```tsx
import React, { useEffect, useRef, useState } from "react";
import { createRoot } from "react-dom/client";
import * as pdfjs from "pdfjs-dist";
import { PDFDocument, StandardFonts, degrees, rgb, type PDFPage } from "pdf-lib";
import { marked } from "marked";
import { zipSync, strToU8, type Zippable } from "fflate";
import katex from "katex";

pdfjs.GlobalWorkerOptions.workerSrc = tb.proxy(
  `https://unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs`
);

// The PDF's text can't smuggle HTML into the preview: marked's HTML tokens (a "<think>" tag in a
// prompt) render as the text they are. Escaping the source instead would double up inside code,
// where marked escapes for itself.
const escapeHtml = (s: string) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
// The sub and sup tags the bulb writes for scripts are the one HTML that renders.
marked.use({ renderer: { html: ({ text }: { text: string }) => /^<\/?su[bp]>$/.test(text) ? text : escapeHtml(text) } });

interface TextItem {
  str: string;
  x: number;
  y: number;
  width: number;
  height: number;
  font: FontInfo;
}

// What a font's name says about it (ext: a math extension font, cmex10's big operators and
// delimiters). pdf.js names a text item's font by an internal id; the font itself, with its real
// name, reaches the page's object store once the operator list is fetched. The MathTime names are
// anchored: loose, a SmText optical size (Source Serif's) reads as MTEX, and a body set in it as math.
interface FontInfo { bold: boolean; italic: boolean; mono: boolean; math: boolean; ext: boolean }
const PLAIN_FONT: FontInfo = { bold: false, italic: false, mono: false, math: false, ext: false };
const fontOf = (name: string): FontInfo => ({
  bold: /Bold|Black|Heavy|Semibold|Demibold|Medi|CMBX|CMB\d|CMSSBX/i.test(name),
  italic: /Ital|Obli|CMTI|CMSL/i.test(name),
  mono: /Mono|NimbusMon|Cour|Cursor|Typewriter|CMTT|Consol|Menlo|Inconsolata|LetterGothic|txtt|SFTT/i.test(name),
  math: /CMMI|CMSY|CMEX|CMBSY|MSAM|MSBM|Math|rsfs|stmry|wasy|txmi|txsy|txex|pxmi|pxsy|pxex|\bMT(?:MI|SY|EX)|eufm|eurm|esint|\bSymbol/i.test(name),
  ext: /CMEX|txex|pxex/i.test(name),
});

// An extension font keeps its big operators at letter codes, which pdf.js hands through as they
// are ("P" for a text-style sum); the common ones read as themselves, and its wide accents as the
// text fonts' (set before their letter: "ˆa"). A tall radical is pieces: a foot (u) under
// extensions (v) under a hook (w), one sign; the biggest fixed one (t) and the Bigg close paren
// (!) read as themselves too.
const EXT = new Map(Object.entries({
  P: "∑", X: "∑", Q: "∏", Y: "∏", R: "∫", Z: "∫", S: "⋃", "[": "⋃", T: "⋂", "\\": "⋂", U: "⊎", "]": "⊎",
  V: "⋀", "^": "⋀", W: "⋁", _: "⋁", "`": "∐", a: "∐", i: "∮", j: "∮", k: "⨀", l: "⨀", m: "⨁", n: "⨁", o: "⨂",
  p: "√", q: "√", r: "√", s: "√", t: "√", u: "√", v: "", w: "", "!": ")", b: "ˆ", c: "ˆ", d: "ˆ", e: "˜", f: "˜", g: "˜",
}));
function pageFonts(page: any, tc: any, names: Set<string>): Map<string, FontInfo> {
  const fonts = new Map<string, FontInfo>();
  for (const it of tc.items) {
    if (!it.fontName || fonts.has(it.fontName)) continue;
    let name = "";
    try { name = page.commonObjs.has(it.fontName) ? page.commonObjs.get(it.fontName)?.name || "" : ""; } catch {}
    names.add(name.replace(/^[A-Z]{6}\+/, "") || "(unnamed)");
    fonts.set(it.fontName, fontOf(name));
  }
  return fonts;
}

// The frame a page reads in. LaTeX sets a landscape page (a wide table) as portrait with /Rotate 90
// and its text turned in the page's own space, which is where pdf.js reports it; the conversion
// works in the frame instead. rot: the rotation (pdf.js's, clockwise degrees) that sets the page's
// dominant text upright, by characters. to/from: the maps between page space and the frame, y up,
// origin at the frame's corner. view: the frame's box.
interface Frame { rot: number; to: number[]; from: number[]; view: number[] }

const pt = (m: number[], x: number, y: number): [number, number] =>
  [m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]];

function pageFrame(items: any[], view: number[]): Frame {
  const chars = [0, 0, 0, 0]; // text reading right, up, left, down
  for (const it of items) {
    const [a, b] = it.transform ?? [1, 0];
    const q = Math.abs(a) >= Math.abs(b) ? (a >= 0 ? 0 : 2) : (b > 0 ? 1 : 3);
    chars[q] += (it.str ?? "").trim().length;
  }
  const [x0, y0, x1, y1] = view;
  const I = [1, 0, 0, 1, 0, 0];
  switch (chars.indexOf(Math.max(...chars))) {
    case 1: return { rot: 90, to: [0, -1, 1, 0, -y0, x1], from: [0, 1, -1, 0, x1, y0], view: [0, 0, y1 - y0, x1 - x0] };
    case 2: return { rot: 180, to: [-1, 0, 0, -1, x0 + x1, y0 + y1], from: [-1, 0, 0, -1, x0 + x1, y0 + y1], view };
    case 3: return { rot: 270, to: [0, 1, -1, 0, y1, -x0], from: [0, -1, 1, 0, x0, y1], view: [0, 0, y1 - y0, x1 - x0] };
    default: return { rot: 0, to: I, from: I, view };
  }
}

interface Line {
  y: number;
  height: number;
  items: TextItem[];
  // 0: the page's one column, or its left; 1: its right; -1: across both (a title, a wide table).
  col: number;
}

// The page's text in its frame, and the text set across the frame: margin decoration (arXiv's
// stamp, an upright page number on a turned page), out of the flow.
function extractItems(textContent: any, fonts: Map<string, FontInfo>, frame: Frame): { items: TextItem[]; margin: string[] } {
  const items: TextItem[] = [];
  const margin: string[] = [];
  for (const it of textContent.items) {
    if (typeof it.str !== "string") continue;
    const t = it.transform;
    const { to } = frame;
    if (Math.abs(to[1] * t[0] + to[3] * t[1]) > Math.abs(to[0] * t[0] + to[2] * t[1])) { margin.push(it.str); continue; }
    const [x, y] = pt(to, t[4], t[5]);
    const font = fonts.get(it.fontName) ?? PLAIN_FONT;
    const height = it.height || Math.abs(t[3]) || 12;
    // A glyph with no Unicode (a math font's, an icon font's) leaks its raw code: below 0x20 it
    // makes grep call the file binary, in 0x80-0x9f it renders as a broken box.
    let str = it.str.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/g, "");
    if (font.ext) str = [...str].map(c => EXT.get(c) ?? c).join("");
    items.push({
      str,
      x,
      // An extension font's glyph hangs below its origin (depth, no height), which TeX therefore
      // sets high, up by the line above; the glyph's middle is where it reads.
      y: font.ext ? y - height * 0.5 : y,
      width: it.width || 0,
      height,
      font,
    });
  }
  return { items, margin };
}

// Cluster items into lines by y-coordinate. PDF y-axis points up,
// so lines are sorted top-of-page first (largest y first).
function groupIntoLines(items: TextItem[], col = 0): Line[] {
  if (!items.length) return [];
  const sorted = [...items].sort((a, b) => b.y - a.y || a.x - b.x);
  const lines: Line[] = [];
  for (const it of sorted) {
    const cur = lines[lines.length - 1];
    if (cur && Math.abs(cur.y - it.y) < it.height * 0.5) {
      cur.items.push(it);
    } else {
      lines.push({ y: it.y, height: it.height, items: [it], col });
    }
  }
  const settle = (ln: Line) => {
    ln.items.sort((a, b) => a.x - b.x);
    ln.height = dominantHeight(ln.items);
    // The baseline is the body's: a superscript sorts first as the highest item, and would set it.
    ln.y = ln.items.find(it => it.height === ln.height && it.str.trim())?.y ?? ln.y;
  };
  lines.forEach(settle);
  // A line of floaters hugging a body line belongs to it: subscripts and an operator's limits set
  // lower than the clustering reaches (a superscript above is caught: the body item comes after
  // it), and, body-sized by their fonts' metrics, an accent set above its letter and a big
  // operator or a radical set off the baseline. Hugging means within reach of the host's text
  // too: a subscript sits by its letter, not by a label across the column.
  const floats = (it: TextItem, nb: Line) =>
    it.height < nb.height * 0.8 || it.font.ext || /^[\^~ˆ˜¯˙¨´`˘ˇ˚⃗√]*$/.test(it.str.trim());
  const near = (it: TextItem, nb: Line) => {
    const e = extent(nb);
    return it.x + it.width >= e.x - nb.height * 1.5 && it.x <= e.right + nb.height * 1.5;
  };
  for (let k = lines.length - 1; k >= 0; k--) {
    const ln = lines[k];
    const host = [lines[k + 1], lines[k - 1]].find(nb => nb && ln.items.every(it => floats(it, nb) && near(it, nb))
      && ln.y >= nb.y - nb.height * 0.5 && ln.y <= nb.y + nb.height * 0.8);
    if (!host) continue;
    host.items.push(...ln.items);
    settle(host);
    lines.splice(k, 1);
  }
  return lines;
}

// Characters set at each height, and their total.
function heightWeights(items: TextItem[]): [Map<number, number>, number] {
  const w = new Map<number, number>();
  let total = 0;
  for (const it of items) {
    const n = it.str.trim().length;
    if (!n) continue;
    w.set(it.height, (w.get(it.height) || 0) + n);
    total += n;
  }
  return [w, total];
}

// Median height weighted by text length: body text sets the baseline even when subscripts
// and footnotes outnumber it as items (LaTeX splits math into hundreds of tiny ones).
function dominantHeight(items: TextItem[]): number {
  const [w, total] = heightWeights(items);
  if (!total) return items.length ? Math.max(...items.map(i => i.height)) : 0;
  let acc = 0;
  for (const [h, n] of [...w.entries()].sort((a, b) => a[0] - b[0])) {
    acc += n;
    if (acc * 2 >= total) return h;
  }
  return 0;
}

// The body size of some lines: the largest height setting a sixth or more of their prose. Larger
// text is a title or a heading, and short; smaller text (footnotes, captions, references, an
// appendix of transcripts) can outrun the body; and a figure's numbers or a table's cells are no
// prose, so don't count. Zero with no text.
function bodyHeight(lines: Line[]): number {
  const prose = lines.filter(isProse);
  const [w, total] = heightWeights((prose.length ? prose : lines).flatMap(ln => ln.items));
  return Math.max(0, ...[...w].filter(([, n]) => n * 6 >= total).map(([h]) => h));
}

// A horizontal gap wider than this fraction of the line height means the PDF
// omitted the space character between items; we restore it on join.
const SPACE_GAP = 0.2;

interface Col { x: number; right: number; text: string }

// Split a line at large horizontal gaps. >=2 runs => candidate table row. Whitespace items
// are skipped: pdf.js encodes a gap as one wide " " item, which would bridge the columns.
// A trailing page-number-like item splits on a smaller gap, for contents pages.
function lineColumns(line: Line): Col[] {
  const cols: Col[] = [];
  const gapThreshold = line.height * 1.1;
  const items = line.items.filter(it => it.str.trim());
  items.forEach((it, i) => {
    const last = cols[cols.length - 1];
    const thr = i === items.length - 1 && /^\s*\d{1,4}\s*$/.test(it.str) ? line.height * 0.8 : gapThreshold;
    if (last && it.x - last.right < thr) {
      if (it.x - last.right > line.height * SPACE_GAP && !last.text.endsWith(" ") && !it.str.startsWith(" "))
        last.text += " ";
      last.text += it.str;
      last.right = Math.max(last.right, it.x + it.width);
    } else {
      cols.push({ x: it.x, right: it.x + it.width, text: it.str });
    }
  });
  return cols
    .map(c => ({ ...c, text: c.text.trim() }))
    .filter(c => c.text);
}

// Math fonts are italic by design: their letters are variables, not emphasis. A typewriter run in
// prose (a URL, an identifier) is code.
const styleOf = (f: FontInfo): Style =>
  f.math ? "" : f.mono ? "c" : (f.bold && f.italic) ? "bi" : f.bold ? "b" : f.italic ? "i" : "";

type Style = "" | "b" | "i" | "bi" | "c";
const tag = (s: Style) =>
  s === "b" ? "**" : s === "i" ? "*" : s === "bi" ? "***" : s === "c" ? "`" : "";

// A run smaller than its line and off its baseline is a superscript (raised) or a subscript (lowered).
type Script = "" | "sup" | "sub";
const scriptOf = (it: TextItem, ln: Line): Script => {
  if (!it.str.trim() || it.height >= ln.height * 0.85) return "";
  const dy = it.y - ln.y;
  return dy > ln.height * 0.2 ? "sup" : dy < -ln.height * 0.08 ? "sub" : "";
};

// Unicode has forms for digits, a few signs, and most letters; a run they cover reads inline
// ("Fan¹", "xᵢ"), and so does a run of signs alone (a footnote mark, a significance star, a prime,
// the comma between affiliations: whatever glyph the font hands over, "Hwang¶" reads unraised).
// Anything else takes <sup> and <sub>, which every markdown viewer renders; pandoc's ^…^ and ~…~
// render nowhere else, and GFM reads a lone tilde as strikethrough.
const scriptMap = (plain: string, forms: string) =>
  new Map([...plain].map((c, i): [string, string] => [c, [...forms][i]]));
const SUP = scriptMap("0123456789+-−=()abcdefghijklmnoprstuvwxyz", "⁰¹²³⁴⁵⁶⁷⁸⁹⁺⁻⁻⁼⁽⁾ᵃᵇᶜᵈᵉᶠᵍʰⁱʲᵏˡᵐⁿᵒᵖʳˢᵗᵘᵛʷˣʸᶻ");
const SUB = scriptMap("0123456789+-−=()aehijklmnoprstuvx", "₀₁₂₃₄₅₆₇₈₉₊₋₋₌₍₎ₐₑₕᵢⱼₖₗₘₙₒₚᵣₛₜᵤᵥₓ");

// Each item's text with its script applied: a run of same-script items is one Unicode string or
// one tagged span.
function scriptedText(line: Line, scripts: Script[]): string[] {
  const out = line.items.map(it => it.str);
  for (let i = 0; i < out.length;) {
    const s = scripts[i];
    if (!s) { i++; continue; }
    let j = i + 1;
    while (j < out.length && scripts[j] === s) j++;
    const map = s === "sup" ? SUP : SUB;
    const run = out.slice(i, j).join("");
    if ([...run].every(c => map.has(c) || /[\p{P}\p{S}\s]/u.test(c))) {
      for (let k = i; k < j; k++) out[k] = [...out[k]].map(c => map.get(c) ?? c).join("");
    } else {
      out[i] = `<${s}>` + out[i];
      out[j - 1] += `</${s}>`;
    }
    i = j;
  }
  return out;
}

// Limits stacked over an operator interleave by x ("ᵢᵐ₌₁"): within a run of scripted items the
// subscripts go first, then the superscripts, as LaTeX writes them (∑_{i=1}^m).
function orderScripts(items: TextItem[], scripts: Script[]): [TextItem[], Script[]] {
  const out: TextItem[] = [], outS: Script[] = [];
  for (let i = 0; i < items.length;) {
    if (!scripts[i]) { out.push(items[i]); outS.push(scripts[i]); i++; continue; }
    let j = i + 1;
    while (j < items.length && (scripts[j] || !items[j].str.trim())) j++;
    for (const s of ["sub", "sup", ""] as Script[]) for (let k = i; k < j; k++) if (scripts[k] === s) { out.push(items[k]); outS.push(s); }
    i = j;
  }
  return [out, outS];
}

// Concatenate items, wrapping bold/italic runs. Whitespace-only items and scripts (a footnote mark
// on a bold name) inherit the previous style so we don't get "**foo** **bar**" splits. Plain: the
// raw text, for a code block, with no style or script marks; otherwise a literal asterisk outside
// code is escaped, so it can't read as one.
function joinLineText(line: Line, plain = false): string {
  const [items, scripts] = orderScripts(line.items, line.items.map(it => scriptOf(it, line)));
  const texts = plain ? items.map(it => it.str)
    : scriptedText({ ...line, items }, scripts).map((s, i) => items[i].font.mono ? s : s.replace(/\*/g, "\\*"));
  let out = "";
  let cur: Style = "";
  let prevRight = -Infinity;
  // A closing mark goes before trailing whitespace, the only place markdown accepts it.
  const close = () => { const tr = out.match(/\s*$/)![0]; out = out.slice(0, out.length - tr.length) + tag(cur) + tr; };
  items.forEach((it, i) => {
    if (!it.str) return;
    const str = texts[i];
    const needSpace = out !== "" && it.x - prevRight > line.height * SPACE_GAP
      && !out.endsWith(" ") && !str.startsWith(" ");
    const s: Style = plain || !it.str.trim() || scripts[i] ? cur : styleOf(it.font);
    if (s !== cur) {
      if (cur) close();
      if (needSpace) out += " ";
      const lead = str.match(/^\s*/)?.[0] ?? "";
      out += lead;
      if (s) out += tag(s);
      out += str.slice(lead.length);
      cur = s;
    } else {
      if (needSpace) out += " ";
      out += str;
    }
    prevRight = it.x + it.width;
  });
  if (cur) close();
  return out;
}

// A heading is text larger than the body: the document's, so a title's level holds on a page of
// transcripts set small, or the page's own where that is larger.
function classifyHeading(line: Line, L: Layout): "h1" | "h2" | "h3" | null {
  const h = line.height, medH = Math.max(L.medH, L.docH);
  if (h > medH * 1.7) return "h1";
  if (h > medH * 1.35) return "h2";
  if (h > medH * 1.1) return "h3";
  return null;
}

function getListMarker(text: string): { kind: "ul" | "ol"; rest: string } | null {
  const ul = text.match(/^[•‣◦⁃·▪●○]\s+(.+)/);
  if (ul) return { kind: "ul", rest: ul[1] };
  const dash = text.match(/^[-*]\s+(.+)/);
  if (dash) return { kind: "ul", rest: dash[1] };
  const ol = text.match(/^\d+[.)]\s+(.+)/);
  if (ol) return { kind: "ol", rest: ol[1] };
  return null;
}

interface Table { rows: string[][]; end: number }

// A table starts at `start` when 2+ rows share the header line's column count and x-positions.
// A line with fewer columns continues the row above when each sits inside the header's column
// (a wrapped cell); a full line does when rows are padded apart and the cell above ran to the
// table's edge. Null when no table starts here.
function detectTable(lines: Line[], start: number): Table | null {
  const head = lines[start];
  if (!head) return null;
  const cols0 = lineColumns(head);
  const n = cols0.length;
  if (n < 2) return null;
  const h = head.height;
  const tol = h * 5;
  // The nearest header column, if within tolerance (columns can sit closer than the tolerance).
  const colOf = (c: Col) => {
    let best = -1, d = tol;
    cols0.forEach((hc, i) => { const di = Math.abs(c.x - hc.x); if (di <= d) { d = di; best = i; } });
    return best;
  };

  type Entry = { cols: Col[]; idx: number[]; gap: number };
  const run: Entry[] = [{ cols: cols0, idx: cols0.map((_, i) => i), gap: 0 }];
  let end = start + 1;
  for (; end < lines.length; end++) {
    const ln = lines[end];
    const cols = lineColumns(ln);
    const gap = lines[end - 1].y - ln.y;
    if (!cols.length || gap > h * 4) break;
    const idx = cols.map(colOf);
    if (idx.some((ci, k) => ci < 0 || (k > 0 && ci <= idx[k - 1]))) break;
    if (cols.length < n) {
      const fits = gap <= h * 1.6 && cols.every((c, k) => idx[k] === n - 1 || c.right <= cols0[idx[k] + 1].x);
      if (!fits) break;
    }
    run.push({ cols, idx, gap });
  }

  const padded = run.some(e => e.gap > h * 1.8);
  const right = Math.max(...run.map(e => e.cols[e.cols.length - 1].right));
  const rows: string[][] = [];
  let prev: Entry | null = null;
  for (const e of run) {
    const wrapped = prev && prev.cols.length === n && prev.cols[n - 1].right >= right - (right - cols0[n - 1].x) * 0.12;
    const cont = prev && (e.cols.length < n || (padded && e.gap <= h * 1.45 && wrapped));
    if (!cont) rows.push(new Array(n).fill(""));
    const row = rows[rows.length - 1];
    e.cols.forEach((c, k) => { row[e.idx[k]] = row[e.idx[k]] ? row[e.idx[k]] + " " + c.text : c.text; });
    prev = e;
  }
  return rows.length < 2 ? null : { rows, end };
}

// A contents run: three or more lines, each a title and a page number, where most carry a dot
// leader or the column is headed Contents. A section number alone is no trigger (a ranked table's
// rows start and end in numbers); it sets an entry's depth, and lets a chapter line without a
// leader into a run the leaders justify. A title wrapped short of its number continues on the
// line under it. Rendered as one nested list, each entry ending in its page; the first entry opens
// at the margin whatever its depth (a list continuing from the page before can't begin nested).
interface Contents { md: string; end: number }
const CONTENTS_HEAD = /^(?:table of )?contents$|^list of (?:figures|tables|algorithms)$/i;
const LEADER = /(?:\s*\.){3,}\s*$/;
// What comes off a title: a leader, or the two spaced dots a long title leaves room for.
const TRAIL = /(?:\s*\.){3,}\s*$|(?:\s\.)+\s*$/;
const SECTION = /^(?:\d+(?:\.\d+)*|[A-Z](?:\.\d+)+)\.?(?=\s)/;
function contentsRun(lines: Line[], start: number, medH: number, headed: boolean): Contents | null {
  type Entry = { x: number; text: string; page: string; sec: string | null };
  const pageOf = (cols: Col[]) => cols.length >= 2 && /^\d{1,4}$/.test(cols[cols.length - 1].text) ? cols[cols.length - 1].text : null;
  const titleOf = (cols: Col[]) => cols.slice(0, -1).map(c => c.text).join(" ");
  const entries: Entry[] = [];
  let end = start;
  for (; end < lines.length; end++) {
    const ln = lines[end];
    if (end > start && lines[end - 1].y - ln.y > ln.height * 4) break;
    const cols = lineColumns(ln);
    if (!cols.length) break;
    let page = pageOf(cols);
    let text = page ? titleOf(cols) : cols.map(c => c.text).join(" ");
    if (!page) {
      const nx = lines[end + 1];
      const nc = nx ? lineColumns(nx) : [];
      const np = pageOf(nc);
      if (!np || !SECTION.test(text) || SECTION.test(titleOf(nc)) || ln.y - nx.y > ln.height * 1.6) break;
      page = np; text = `${text} ${titleOf(nc)}`; end++;
    }
    const sec = text.match(SECTION)?.[0] ?? null;
    if (!text.replace(TRAIL, "").trim() || !(sec || LEADER.test(text) || headed)) break;
    entries.push({ x: cols[0].x, text, page, sec });
  }
  if (entries.length < 3 || !(headed || entries.filter(e => LEADER.test(e.text)).length * 2 >= entries.length)) return null;
  const x0 = Math.min(...entries.map(e => e.x));
  const depths = entries.map(e => e.sec ? e.sec.replace(/\.$/, "").split(".").length : 1 + Math.round((e.x - x0) / (medH * 2)));
  const d0 = Math.min(...depths);
  const md = entries.map((e, k) => `${k ? "  ".repeat(Math.min(depths[k] - d0, 3)) : ""}- ${e.text.replace(TRAIL, "").trim()} … ${e.page}\n`).join("");
  return { md, end };
}

function renderTable(rows: string[][]): string {
  const esc = (s: string) => s.replace(/\|/g, "\\|");
  let out = "| " + rows[0].map(esc).join(" | ") + " |\n";
  out += "|" + " --- |".repeat(rows[0].length) + "\n";
  for (const r of rows.slice(1)) out += "| " + r.map(esc).join(" | ") + " |\n";
  return out;
}

// Layout facts: the body font size (the document's, or a page's own where it sets little in it),
// the document's own, and the text column's extent, measured document-wide on the pages set the
// document's way (rot); a page turned the other way (a wide table) has no known column.
interface Layout { medH: number; docH: number; textWidth: number; left: number; right: number; rot: number }

interface Page { lines: Line[]; frame: Frame }

// A line's text extent (a whitespace item, which pdf.js can set wide, has no ink).
const extent = (ln: Line) => {
  const its = ln.items.filter(it => it.str.trim());
  const xs = its.length ? its : ln.items;
  return { x: Math.min(...xs.map(it => it.x)), right: Math.max(...xs.map(it => it.x + it.width)) };
};

function measureLayout(pages: Page[]): Layout {
  const medH = bodyHeight(pages.flatMap(p => p.lines)) || 12;
  const count = (r: number) => pages.filter(p => p.frame.rot === r).length;
  const rot = [0, 90, 180, 270].reduce((a, b) => count(b) > count(a) ? b : a);
  const body = pages.filter(p => p.frame.rot === rot).flatMap(p => p.lines)
    .filter(ln => ln.items.length && ln.height >= medH * 0.9).map(extent);
  const spans = body.map(e => e.right - e.x).sort((a, b) => a - b);
  // The 90th percentile, not the max: one stray line parked at a huge off-page coordinate
  // would otherwise define the column for the whole document.
  const textWidth = spans.length ? spans[Math.floor((spans.length - 1) * 0.9)] : 0;
  const wide = body.filter(e => { const s = e.right - e.x; return s >= textWidth * 0.5 && s <= textWidth * 1.5; });
  return {
    medH,
    docH: medH,
    textWidth,
    left: Math.min(Infinity, ...wide.map(e => e.x)),
    right: Math.max(-Infinity, ...wide.map(e => e.right)),
    rot,
  };
}

// Body text is body-sized or wide; anything else (axis labels, legends) may live inside a figure.
const isBody = (ln: Line, L: Layout) => {
  const e = extent(ln);
  return ln.height >= L.medH * 0.9 || e.right - e.x >= L.textWidth * 0.35;
};

const span = (its: TextItem[]) => Math.max(...its.map(it => it.x + it.width)) - Math.min(...its.map(it => it.x));

// What a line has either side of x: the text on each (an item across x counts on both), the gap
// between the two (negative then, NaN with a side empty), and whether each side is prose: one run
// with no gap of a column separation inside, running most of its half of the page's text extent
// [left, right]. A table row or a chart's tick labels have the gaps; a wrapped paragraph doesn't.
function acrossX(ln: Line, x: number, m: number, left: number, right: number) {
  const l: TextItem[] = [], r: TextItem[] = [];
  for (const it of ln.items) {
    if (!it.str.trim()) continue;
    if (it.x < x) l.push(it);
    if (it.x + it.width > x) r.push(it);
  }
  const gap = l.length && r.length ? Math.min(...r.map(it => it.x)) - Math.max(...l.map(it => it.x + it.width)) : NaN;
  const prose = (its: TextItem[], w: number) =>
    its.length > 0 && its.every((it, i) => !i || it.x - (its[i - 1].x + its[i - 1].width) < m * 0.9) && span(its) >= w * 0.45;
  return { l, r, both: l.length > 0 && r.length > 0, gap, lProse: prose(l, x - left), rProse: prose(r, right - x) };
}

// A line of two columns' prose: prose either side of x, a column separation apart.
const proseAcross = (ln: Line, x: number, m: number, left: number, right: number) => {
  const a = acrossX(ln, x, m, left, right);
  return a.gap >= m * 0.9 && a.lProse && a.rProse;
};

// The text extent of a page's body lines (infinite when there are none).
function bodyExtent(lines: Line[], m: number): { left: number; right: number } {
  const ext = lines.filter(ln => ln.height >= m * 0.9 && ln.items.some(it => it.str.trim())).map(extent);
  return { left: Math.min(...ext.map(e => e.x)), right: Math.max(...ext.map(e => e.right)) };
}

// A two-column page's gutter: the x that the most body lines break across as prose, when they are
// a good share of the page's body lines and it sits mid-page. Null when the page reads as one column.
function findGutter(lines: Line[], m: number): number | null {
  const body = lines.filter(ln => ln.height >= m * 0.9 && ln.items.some(it => it.str.trim()));
  if (body.length < 4) return null;
  const { left, right } = bodyExtent(body, m);
  // Candidates: the middle of each line's gaps of a column separation's width.
  const cands = new Set<number>();
  for (const ln of body) {
    let r = -Infinity;
    for (const it of ln.items) {
      if (!it.str.trim()) continue;
      if (isFinite(r) && it.x - r >= m * 0.9) cands.add(Math.round((r + it.x) / 2));
      r = Math.max(r, it.x + it.width);
    }
  }
  const w = right - left;
  let best: number | null = null;
  let most = Math.max(4, Math.ceil(body.length * 0.35)) - 1;
  for (const c of cands) {
    if (c < left + w * 0.3 || c > right - w * 0.3) continue;
    const n = body.filter(ln => proseAcross(ln, c, m, left, right)).length;
    if (n > most) { most = n; best = c; }
  }
  if (best === null) return null;
  // The gutter's middle: the median, over the lines that break there, of their gap's middle.
  const x = best;
  const mids = body.filter(ln => proseAcross(ln, x, m, left, right))
    .map(ln => { const { l, r } = acrossX(ln, x, m, left, right); return (Math.max(...l.map(it => it.x + it.width)) + Math.min(...r.map(it => it.x))) / 2; })
    .sort((a, b) => a - b);
  return mids[mids.length >> 1];
}

// The page's lines cut at the gutter: a line with a gap across it is a line in each column, a line
// with text across it spans both (a title, an abstract), and the rest lie in one. A table across
// the gutter keeps its rows whole: rows with text on both sides and prose on neither (two columns'
// prose detects as a two-column table, and is cut; a column's table beside prose is cut too). With
// the lines, each column's layout: its edges, from its body lines.
function columnize(lines: Line[], gutter: number | null, L: Layout): { lines: Line[]; layoutOf: (col: number) => Layout } {
  if (gutter === null) return { lines, layoutOf: () => L };
  const m = L.medH;
  const { left, right } = bodyExtent(lines, m);
  const whole = new Set<Line>();
  for (let i = 0; i < lines.length;) {
    const t = detectTable(lines, i);
    if (!t) { i++; continue; }
    const rows = lines.slice(i, t.end);
    const as = rows.map(ln => acrossX(ln, gutter, m, left, right));
    const across = as.filter(a => a.both).length;
    const prosy = as.filter(a => a.lProse || a.rProse).length;
    if (across * 2 >= rows.length && prosy * 4 <= rows.length) for (const ln of rows) whole.add(ln);
    i = t.end;
  }
  const cols: TextItem[][] = [[], []];
  const out: Line[] = [];
  for (const ln of lines) {
    const a = acrossX(ln, gutter, m, left, right);
    if (whole.has(ln) || (a.both && a.gap < m * 0.9)) { out.push({ ...ln, col: -1 }); continue; }
    for (const it of ln.items) cols[it.x + it.width / 2 < gutter ? 0 : 1].push(it);
  }
  out.push(...groupIntoLines(cols[0], 0), ...groupIntoLines(cols[1], 1));
  out.sort((a, b) => b.y - a.y || a.col - b.col);
  const layouts = [0, 1].map(c => {
    const e = bodyExtent(out.filter(ln => ln.col === c), m);
    return isFinite(e.left) ? { ...L, left: e.left, right: e.right, textWidth: e.right - e.left } : L;
  });
  return { lines: out, layoutOf: col => (col < 0 ? L : layouts[col]) };
}

interface Band { top: number; bottom: number }

// What sits in a page's flow besides its text (a figure's reference, a code block), placed by the
// band it occupies and the column it sits in.
interface Block extends Band { md: string; col: number }

interface Box { x0: number; y0: number; x1: number; y1: number }
// What the page draws, as evidence of what a region is: an image; a rule or a rect (a fraction
// bar, a frame, a table's line: furniture); or a curve or polyline, weighted by its coordinate count.
interface Graphic extends Box { weight: number; rect: boolean; image: boolean }

const mul = (c: number[], t: number[]) => [
  c[0] * t[0] + c[2] * t[1], c[1] * t[0] + c[3] * t[1],
  c[0] * t[2] + c[2] * t[3], c[1] * t[2] + c[3] * t[3],
  c[0] * t[4] + c[2] * t[5] + c[4], c[1] * t[4] + c[3] * t[5] + c[5],
];

function boxUnder(m: number[], x0: number, y0: number, x1: number, y1: number): Box {
  const xs: number[] = [], ys: number[] = [];
  for (const [x, y] of [[x0, y0], [x1, y0], [x0, y1], [x1, y1]]) {
    xs.push(m[0] * x + m[2] * y + m[4]);
    ys.push(m[1] * x + m[3] * y + m[5]);
  }
  return { x0: Math.min(...xs), y0: Math.min(...ys), x1: Math.max(...xs), y1: Math.max(...ys) };
}

// The part of a inside b (all of a with no clip), or null. A rule's box has no width or no height,
// and keeps it through the cut.
const intersect = (a: Box, b: Box | null): Box | null => {
  if (!b) return a;
  const r = { x0: Math.max(a.x0, b.x0), y0: Math.max(a.y0, b.y0), x1: Math.min(a.x1, b.x1), y1: Math.min(a.y1, b.y1) };
  const has = (lo: number, hi: number, flat: boolean) => hi > lo || (hi === lo && flat);
  return has(r.x0, r.x1, a.x0 === a.x1) && has(r.y0, r.y1, a.y0 === a.y1) ? r : null;
};

// Where the page draws, in its frame: every image and path as a box. Read off the operator list,
// tracking the CTM and the clip through save/restore/transform and form XObjects (an image is
// often placed far larger than its clip shows).
async function pageGraphics(page: any, frame: Frame): Promise<Graphic[]> {
  const { fnArray, argsArray } = await page.getOperatorList();
  const O = pdfjs.OPS;
  let ctm = frame.to;
  let clip: Box | null = null;
  let pendingClip = false;
  let lastPath: Box | null = null;
  const applyClip = (box: Box) => { clip = intersect(box, clip) ?? { x0: 0, y0: 0, x1: 0, y1: 0 }; };
  const stack: { ctm: number[]; clip: Box | null }[] = [];
  const out: Graphic[] = [];
  const add = (box: Box, weight: number, rect: boolean, image: boolean) => {
    const b = intersect(box, clip);
    if (b) out.push({ ...b, weight, rect, image });
  };
  fnArray.forEach((fn: number, i: number) => {
    const a = argsArray[i];
    if (fn === O.save) stack.push({ ctm, clip });
    else if (fn === O.restore) ({ ctm, clip } = stack.pop() ?? { ctm, clip });
    else if (fn === O.transform) ctm = mul(ctm, a);
    else if (fn === O.paintFormXObjectBegin) {
      // A form's bbox clips its content.
      stack.push({ ctm, clip });
      if (a[0]) ctm = mul(ctm, a[0]);
      if (a[1]?.length === 4) applyClip(boxUnder(ctm, a[1][0], a[1][1], a[1][2], a[1][3]));
    }
    else if (fn === O.paintFormXObjectEnd) ({ ctm, clip } = stack.pop() ?? { ctm, clip });
    // The clip op comes after the path it applies to (or, in some streams, before it).
    else if (fn === O.clip || fn === O.eoClip) { if (lastPath) applyClip(lastPath); else pendingClip = true; }
    else if (fn === O.paintImageXObject || fn === O.paintInlineImageXObject || fn === O.paintImageMaskXObject)
      add(boxUnder(ctm, 0, 0, 1, 1), 0, false, true);
    else if (fn === O.constructPath && a[2]?.length === 4 && isFinite(a[2][0])) {
      const box = boxUnder(ctm, a[2][0], a[2][1], a[2][2], a[2][3]);
      if (a[0] !== O.endPath) {
        // A rule or a rect is up to 13 numbers; a curve or polyline runs on.
        const len = (a[1] || []).reduce((s: number, p: any) => s + (p?.length || 0), 0);
        add(box, len, len <= 13, false);
      }
      if (pendingClip) { applyClip(box); pendingClip = false; }
      lastPath = box;
    }
    if (fn !== O.constructPath) lastPath = null;
  });
  return out;
}

const union = (a: Box, b: Box): Box =>
  ({ x0: Math.min(a.x0, b.x0), y0: Math.min(a.y0, b.y0), x1: Math.max(a.x1, b.x1), y1: Math.max(a.y1, b.y1) });

// The page's render, and the frame it reads in: a box in the frame as pixels of the render (y
// down), and back. Both maps turn by right angles, so a box maps by its corners.
interface Render { canvas: HTMLCanvasElement; vp: any; frame: Frame }

const boxOf = (a: number[], b: number[]): Box =>
  ({ x0: Math.min(a[0], b[0]), y0: Math.min(a[1], b[1]), x1: Math.max(a[0], b[0]), y1: Math.max(a[1], b[1]) });

function toPx(R: Render, b: Box): Box {
  const p = boxUnder(R.frame.from, b.x0, b.y0, b.x1, b.y1);
  return boxOf(R.vp.convertToViewportPoint(p.x0, p.y0), R.vp.convertToViewportPoint(p.x1, p.y1));
}

function fromPx(R: Render, b: Box): Box {
  const [ax, ay] = R.vp.convertToPdfPoint(b.x0, b.y0);
  const [bx, by] = R.vp.convertToPdfPoint(b.x1, b.y1);
  return boxOf(pt(R.frame.to, ax, ay), pt(R.frame.to, bx, by));
}

// The render's ink, a point per cell (pixels over the scale, y down): a cell is inked when any of
// its pixels is darker than paper. The flow text is cleared: it bounds the page's other content
// rather than being it.
interface Ink { cells: Uint8Array; w: number; h: number }

function inkMask(R: Render, flow: Box[]): Ink {
  const { canvas, vp } = R;
  const s = vp.scale;
  const w = Math.ceil(canvas.width / s), h = Math.ceil(canvas.height / s);
  const cells = new Uint8Array(w * h);
  const px = canvas.getContext("2d")!.getImageData(0, 0, canvas.width, canvas.height).data;
  for (let y = 0, i = 0; y < canvas.height; y++) {
    const row = Math.floor(y / s) * w;
    for (let x = 0; x < canvas.width; x++, i += 4)
      if (px[i] < 235 || px[i + 1] < 235 || px[i + 2] < 235) cells[row + Math.floor(x / s)] = 1;
  }
  for (const b of flow) {
    const p = toPx(R, b);
    const x0 = Math.max(0, Math.floor(p.x0 / s)), x1 = Math.min(w, Math.ceil(p.x1 / s));
    for (let y = Math.max(0, Math.floor(p.y0 / s)); y < Math.min(h, Math.ceil(p.y1 / s)); y++) cells.fill(0, y * w + x0, y * w + x1);
  }
  return { cells, w, h };
}

// The ink clustered into regions: cells of c points, joined across a cell (a fraction's bar and
// its numerator, an axis and its ticks). A region's box is its ink's, exactly, in the frame.
function inkRegions(R: Render, ink: Ink, c: number): Box[] {
  const { cells, w, h } = ink;
  const cw = Math.ceil(w / c), ch = Math.ceil(h / c);
  const coarse = new Uint8Array(cw * ch);
  for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) if (cells[y * w + x]) coarse[Math.floor(y / c) * cw + Math.floor(x / c)] = 1;
  const seen = new Uint8Array(cw * ch);
  const s = R.vp.scale;
  const out: Box[] = [];
  for (let start = 0; start < coarse.length; start++) {
    if (!coarse[start] || seen[start]) continue;
    const box = { x0: Infinity, y0: Infinity, x1: -Infinity, y1: -Infinity };
    const stack = [start];
    seen[start] = 1;
    while (stack.length) {
      const k = stack.pop()!;
      const cx = k % cw, cy = (k - cx) / cw;
      for (let y = cy * c; y < Math.min(h, (cy + 1) * c); y++)
        for (let x = cx * c; x < Math.min(w, (cx + 1) * c); x++)
          if (cells[y * w + x]) { box.x0 = Math.min(box.x0, x); box.x1 = Math.max(box.x1, x + 1); box.y0 = Math.min(box.y0, y); box.y1 = Math.max(box.y1, y + 1); }
      for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {
        const nx = cx + dx, ny = cy + dy, k2 = ny * cw + nx;
        if (nx >= 0 && ny >= 0 && nx < cw && ny < ch && coarse[k2] && !seen[k2]) { seen[k2] = 1; stack.push(k2); }
      }
    }
    out.push(fromPx(R, { x0: box.x0 * s, y0: box.y0 * s, x1: box.x1 * s, y1: box.y1 * s }));
  }
  return out;
}

// A line set in a box's band: its baseline no lower than a quarter line under the box, its
// x-height under the top. A lead-in word over a tall display sits above its band, whatever its
// delimiter's tip clears.
const inBand = (ln: Line, r: Box) => ln.y >= r.y0 - ln.height * 0.25 && ln.y + ln.height * 0.4 <= r.y1;

// The share of a line's text sitting in the region: its baseline band inside, with a little
// slack sideways for labels. By characters, because lines are clustered by y across the page: a
// paragraph wrapped beside a figure shares a line with the figure's labels.
function ownedShare(ln: Line, r: Box, m: number): number {
  // An extension-font line (a big operator on a line of its own) reads at its middle and hangs far
  // either way; a radical of any size is set from its top, and hangs too.
  const ext = ln.items.every(it => it.font.ext || !it.str.trim() || (it.font.math && /^√+$/.test(it.str.trim())));
  if (ext ? ln.y < r.y0 - ln.height || ln.y > r.y1 + ln.height : !inBand(ln, r)) return 0;
  let inside = 0, total = 0;
  for (const it of ln.items) {
    const k = it.str.trim().length;
    if (!k) continue;
    total += k;
    const cx = it.x + it.width / 2;
    if (cx >= r.x0 - m && cx <= r.x1 + m) inside += k;
  }
  return total ? inside / total : 0;
}

// How much of the region's width a line runs across (a line beside a wrapped figure: none).
function overlapX(ln: Line, r: Box): number {
  const e = extent(ln);
  return Math.max(0, Math.min(e.right, r.x1) - Math.max(e.x, r.x0)) / (r.x1 - r.x0 || 1);
}

// A line of prose: four words or more in text fonts (a figure's labels and an axis title have
// fewer; an equation's variables are words in a math font), in one run: a row of panel titles
// has the words, and the gaps.
const isProse = (ln: Line) => ln.items.reduce((k, it) => k + (it.font.math ? 0 : (it.str.match(/[A-Za-z]{3,}/g) || []).length), 0) >= 4;
const isProseRun = (ln: Line) => isProse(ln) && lineColumns(ln).length === 1;

// Crops the rect out of the page render, with the holes (text from outside the region that reaches
// into the crop: a caption's ascenders) painted white, then trimmed to its ink. Null when blank.
async function cropFigure(canvas: HTMLCanvasElement, vp: any, rect: Box, holes: Box[] = []): Promise<Uint8Array | null> {
  const [ax, ay] = vp.convertToViewportPoint(rect.x0, rect.y1);
  const [bx, by] = vp.convertToViewportPoint(rect.x1, rect.y0);
  const x0 = Math.max(0, Math.floor(Math.min(ax, bx)));
  const y0 = Math.max(0, Math.floor(Math.min(ay, by)));
  const w = Math.min(canvas.width, Math.ceil(Math.max(ax, bx))) - x0;
  const h = Math.min(canvas.height, Math.ceil(Math.max(ay, by))) - y0;
  if (w < 2 || h < 2) return null;
  const cut = document.createElement("canvas");
  cut.width = w;
  cut.height = h;
  const g = cut.getContext("2d")!;
  g.drawImage(canvas, x0, y0, w, h, 0, 0, w, h);
  g.fillStyle = "#fff";
  for (const hole of holes) {
    const [hx0, hy0] = vp.convertToViewportPoint(hole.x0, hole.y1);
    const [hx1, hy1] = vp.convertToViewportPoint(hole.x1, hole.y0);
    g.fillRect(Math.min(hx0, hx1) - x0, Math.min(hy0, hy1) - y0, Math.abs(hx1 - hx0), Math.abs(hy1 - hy0));
  }
  const px = g.getImageData(0, 0, w, h).data;
  let minX = w, minY = h, maxX = -1, maxY = -1;
  for (let y = 0; y < h; y++) {
    for (let x = 0; x < w; x++) {
      const i = (y * w + x) * 4;
      if (px[i] < 235 || px[i + 1] < 235 || px[i + 2] < 235) {
        if (x < minX) minX = x;
        if (x > maxX) maxX = x;
        if (y < minY) minY = y;
        if (y > maxY) maxY = y;
      }
    }
  }
  if (maxX < 0 || maxY - minY < 4) return null;
  const m = Math.round(vp.scale * 4);
  const cx = Math.max(0, minX - m), cy = Math.max(0, minY - m);
  const cw = Math.min(w, maxX + m + 1) - cx, ch = Math.min(h, maxY + m + 1) - cy;
  const out = document.createElement("canvas");
  out.width = cw;
  out.height = ch;
  out.getContext("2d")!.drawImage(cut, cx, cy, cw, ch, 0, 0, cw, ch);
  const blob = await new Promise<Blob>((res, rej) =>
    out.toBlob(b => (b ? res(b) : rej(new Error("PNG encode failed"))), "image/png"));
  return new Uint8Array(await blob.arrayBuffer());
}

// A figure's file: what the document, the folder, and the zip carry.
interface FigureFile { file: string; png: Uint8Array }

// Pages render at three pixels a point (216 DPI): a crop's pixel size over the scale is its size
// on the page, which the preview sizes it by.
const RENDER_SCALE = 3;
const pngDim = (png: Uint8Array) => {
  const v = new DataView(png.buffer, png.byteOffset, png.byteLength);
  return { w: v.getUint32(16), h: v.getUint32(20) };
};

// A figure while its page is assembled: the file, its crop (where it sits in the flow), its
// caption, and for a display equation its numbers and its text layer (its LaTeX is checked
// against it).
interface Figure extends Band, FigureFile { alt: string; col: number; eq?: { tags: string[]; text: string } }

const CAPTION = /^(Figure|Fig\.?|Table|Chart|Exhibit|Plate)\s*[\dA-Z]+\s*[.:\-–—]/i;
const FIG_CAPTION = /^(Figure|Fig\.?|Chart|Exhibit|Plate)\s*[\dA-Z]+\s*[.:\-–—]/i;
const cleanAlt = (s: string) => s.replace(/[\[\]()]/g, "").replace(/\s+/g, " ").trim().slice(0, 140);

// A line's text under the box (a wrapped figure's caption shares its line with the paragraph
// beside it), plain.
function textUnder(ln: Line, r: Box, m: number): string {
  const items = ln.items.filter(it => { const cx = it.x + it.width / 2; return cx >= r.x0 - m && cx <= r.x1 + m; });
  return joinLineText({ ...ln, items }, true).trim();
}

// A line's caption text: the text under the box when that reads as one (a wrapped figure's
// caption shares its line with the paragraph beside it), else the whole line when it runs across
// the box (a caption set wider than its figure, from the margin). Empty for no caption.
function captionText(ln: Line, r: Box, m: number): string {
  const under = textUnder(ln, r, m);
  if (CAPTION.test(under)) return under;
  const all = joinLineText(ln, true).trim();
  return overlapX(ln, r) >= 0.5 && CAPTION.test(all) ? all : "";
}
const isCaption = (ln: Line, r: Box, m: number) => captionText(ln, r, m) !== "";

// The caption: the first caption-shaped line inside the crop or within a few lines under it (an
// axis title and the caption skip sit between the ink and the caption), else the one right above.
function captionFor(lines: Line[], L: Layout, rect: Box, fallback: string): string {
    const m = L.medH;
    const hit = lines.find(ln => ln.y <= rect.y1 && ln.y >= rect.y0 - m * 6 && isCaption(ln, rect, m))
      ?? [...lines].reverse().find(ln => ln.y > rect.y1 && ln.y < rect.y1 + m * 1.5 && isCaption(ln, rect, m));
    return hit ? cleanAlt(captionText(hit, rect, m)) : fallback;
}

const hashes = (h: "h1" | "h2" | "h3") => (h === "h1" ? "#" : h === "h2" ? "##" : "###");

// A wrapped line joins its predecessor; a line-break hyphen after a letter goes ("informa-" + "tion").
const joinWrapped = (acc: string, next: string) =>
  /[a-zA-Z]-$/.test(acc) ? acc.slice(0, -1) + next : acc + " " + next;

// A line set in the middle of the column, clear of both edges by the same margin.
function centered(ln: Line, L: Layout): boolean {
  if (!isFinite(L.left) || !isFinite(L.right)) return false;
  const e = extent(ln);
  const lg = e.x - L.left, rg = L.right - e.right;
  return lg > L.medH * 2 && rg > L.medH * 2 && Math.abs(lg - rg) <= L.medH * 0.5;
}

// A section title set in body-sized capitals ("ABSTRACT", "1 INTRODUCTION", "2.1 SETUP"): a short
// line of capitals with room above it. A numbered one nests by its depth.
function capsHeading(text: string, ln: Line, prev: Line | undefined, L: Layout): "h2" | "h3" | null {
  const m = text.replace(/\*/g, "").match(/^(?:(\d+(?:\.\d+)*)\.?\s+)?([A-Z][A-Z0-9 ,:&'()-]*[A-Z0-9)])$/);
  if (!m || (m[2].match(/[A-Z]/g) || []).length < 4) return null;
  const e = extent(ln);
  if (e.right - e.x > L.textWidth * 0.7) return null;
  if (prev && prev.y - ln.y <= ln.height * 1.3) return null;
  return m[1]?.includes(".") ? "h3" : "h2";
}

const isPageNumber = (ln: Line) => /^\d{1,4}$/.test(joinLineText(ln, true).trim());

// A heading is emphasis enough: its style marks go, every unescaped asterisk (a title's footnote
// star stays).
const unstyled = (s: string) => s.replace(/(?<!\\)\*+/g, "");

// The share of the items' characters in items the test accepts.
function itemShare(items: TextItem[], test: (it: TextItem) => boolean): number {
  let hit = 0, total = 0;
  for (const it of items) { const k = it.str.trim().length; total += k; if (test(it)) hit += k; }
  return total ? hit / total : 0;
}
const fontShare = (ln: Line, test: (f: FontInfo) => boolean) => itemShare(ln.items, it => test(it.font));
// A math glyph: set in a math font, or a lone italic letter in a text font (mathpazo and mathptmx
// set math italics from the text face, so a variable is the text's italic).
const isMathGlyph = (it: TextItem) => it.font.math || (it.font.italic && /^\s*\p{L}\s*$/u.test(it.str));
const isMono = (ln: Line) => fontShare(ln, f => f.mono) >= 0.6;
// A listing is two or more monospace lines a line or two apart; a lone one (a URL) is prose.
function monoRunEnd(lines: Line[], i: number): number {
  let j = i + 1;
  while (j < lines.length && isMono(lines[j]) && lines[j - 1].y - lines[j].y <= lines[j - 1].height * 2.5) j++;
  return j;
}
const startsListing = (lines: Line[], i: number) => isMono(lines[i]) && monoRunEnd(lines, i) - i >= 2;

// A display equation's line: math-font glyphs among its body-sized ones (its digits and brackets
// come from the text fonts; its subscripts don't count) with a variable among them (a table's
// stars and ticks are math glyphs too), set where prose isn't: centered in the column, well clear
// of its left edge, or tagged at its right edge; and few words. A row continuing an equation
// needs only its math share and a few words (a wide equation's main line sits off center, pushed
// by its number; a fraction's numerator can be a text-font "1"), and one ending in the equation's
// tag belongs whatever it holds.
// An equation's tag: its number, or a word ((CR), (IC)), in parentheses.
const TAG = String.raw`\((\d+[a-z]?|[A-Z][A-Za-z]{0,5}\d?)\)`;
const TAG_END = new RegExp(TAG + "$"), TAG_ALONE = new RegExp("^" + TAG + "$");
const isEquationLine = (ln: Line, L: Layout, cont = false) => {
  const text = joinLineText(ln, true).trim();
  const e = extent(ln);
  const m = L.medH;
  const numbered = TAG_END.test(text) && e.right >= L.right - m;
  const body = ln.items.filter(it => it.height >= ln.height * 0.85 && it.str.trim());
  const share = itemShare(body.length ? body : ln.items, isMathGlyph);
  const words = ln.items.filter(it => !it.font.math).reduce((k, it) => k + (it.str.match(/[A-Za-z]{3,}/g) || []).length, 0);
  if (cont) return numbered || (share >= 0.15 && words <= 6);
  if (share < 0.15 || !ln.items.some(it => isMathGlyph(it) && /[\p{L}\p{N}]/u.test(it.str))) return false;
  const lg = e.x - L.left, rg = L.right - e.right;
  const set = isFinite(L.left) && isFinite(L.right)
    ? lg > m * 2 || (lg > m * 0.5 && Math.abs(lg - rg) <= m) || numbered
    : share >= 0.5;
  return set && words <= 3;
};

// Lines that stop short of the block's edge where no paragraph ends: a listing, not prose. A
// paragraph ends on the block's last line, on an item ("1.", "(ii)") or before one, where the
// next line starts elsewhere (an indent, the edge after a list's wrapped lines), and before what
// was cut out of the block (a display's lines are the ones missing from `kept`).
function ragged(kept: Line[], all: Line[]): boolean {
  const sorted = [...all].sort((a, b) => b.y - a.y);
  const right = Math.max(...kept.map(ln => extent(ln).right));
  const width = right - Math.min(...kept.map(ln => extent(ln).x));
  const item = (ln: Line) => { const t = joinLineText(ln, true).trim(); return !!getListMarker(t) || /^\((?:[ivx]+|[a-z]|\d+)\)\s/i.test(t); };
  const ends = (i: number) => i === sorted.length - 1 || !kept.includes(sorted[i + 1]) || item(sorted[i]) || item(sorted[i + 1])
    || Math.abs(extent(sorted[i + 1]).x - extent(sorted[i]).x) > sorted[i].height * 0.5;
  const short = sorted.filter((ln, i) => kept.includes(ln) && !ends(i) && extent(ln).right < right - width * 0.15).length;
  return short >= Math.max(1, (kept.length - 1) * 0.3);
}

// A block's lines as a fenced code block, one per line, indented by where each starts.
function fenced(lines: Line[]): string {
  const sorted = [...lines].sort((a, b) => b.y - a.y);
  const startX = (ln: Line) => (ln.items.find(it => it.str.trim()) ?? ln.items[0]).x;
  const left = Math.min(...sorted.map(startX));
  const rows = sorted.map(ln => " ".repeat(Math.round((startX(ln) - left) / (ln.height * 0.6))) + joinLineText(ln, true).trim());
  return "```\n" + rows.join("\n") + "\n```\n\n";
}

// A line the text keeps: prose, a heading, a list item, a listing's line, a caption, a page
// number, or a body-sized line in text fonts starting at the column's edge (a paragraph's last
// line, a "Therefore,"). The rest is a label, a tick, a limit: content of what the page draws.
function isFlow(ln: Line, L: Layout, edge: number): boolean {
  const text = joinLineText(ln, true).trim();
  if (!text) return false;
  // A lone letter, however large, is a panel's label, not a heading.
  if (/^\(?[A-Za-z]\)?\.?$/.test(text)) return false;
  if (isProseRun(ln) || isMono(ln) || CAPTION.test(text) || isPageNumber(ln) || getListMarker(text)) return true;
  if (classifyHeading(ln, L) || capsHeading(text, ln, undefined, L)) return true;
  return ln.height >= L.medH * 0.9 && fontShare(ln, f => f.math) < 0.5 && extent(ln).x <= edge + L.medH;
}

// What a page holds besides its text: the figures and display equations cut out of its render,
// with the lines they take with them, and the boxed listings kept as code blocks.
interface Cuts { figures: Figure[]; blocks: Block[]; drop: Set<Line> }

interface PageIn { page: any; p: number; n: number; pad: (i: number) => string; lines: Line[]; frame: Frame; L: Layout; layoutOf: (col: number) => Layout; gutter: number | null }

type Kind = "figure" | "equation" | "stray" | "text";
interface Region extends Box { kind: Kind; note: string; why: string; cap: boolean; took: number }
const at = (r: Box) => `${r.x0.toFixed(0)}-${r.x1.toFixed(0)}×${r.y0.toFixed(0)}-${r.y1.toFixed(0)}`;

// The ink decides where a region is; the text layer and the operator list decide what it is. The
// flow text is cleared from the ink first, so a region is what sits between the text, and its box
// is its ink's, complete: nothing estimated from a glyph's metrics or a path's placement to fall
// short. Each region's kind comes from what lies in it: a caption, an image, or real drawing makes
// a figure; mostly flow text makes a text box, which stays text; math makes an equation; ten drawn
// elements with labels among them make a diagram. A figure then takes what sits within three line
// heights of it with no flow line between (an axis title, a diagram's rows, a subfigure's label),
// and an equation takes the next equation (a derivation's rows).
async function cutPage(P: PageIn, canvas: HTMLCanvasElement, onProgress: (s: string) => void): Promise<Cuts> {
  const { page, p, n, pad, lines, frame, L, layoutOf, gutter } = P;
  const m = L.medH;
  const view = frame.view;
  const pageArea = (view[2] - view[0]) * (view[3] - view[1]);
  const drop = new Set<Line>();
  const blocks: Block[] = [];
  const figures: Figure[] = [];
  // A box's column: the one it sits in, or both when it crosses the gutter (a wide figure).
  const colOf = (b: Box) => gutter === null ? 0 : b.x0 < gutter - m && b.x1 > gutter + m ? -1 : (b.x0 + b.x1) / 2 < gutter ? 0 : 1;
  const inCol = (ln: Line, r: Box) => ln.col < 0 || colOf(r) < 0 || ln.col === colOf(r);
  // A rule running most of the page and past the text column is furniture, not content: a margin
  // or column rule, a page frame's side, the line under a running head. It leaves the ink with the
  // flow text, or the ink would join whatever it runs past into one region. A table's rule or a
  // chart's axis runs within the column.
  const furniture = (g: Graphic) => g.rect && (
    (g.y1 - g.y0 >= (view[3] - view[1]) * 0.5 && g.x1 - g.x0 < m * 0.3
      && (g.x0 < L.left || g.x1 > L.right || (gutter !== null && Math.abs((g.x0 + g.x1) / 2 - gutter) < m)))
    || (g.x1 - g.x0 >= (view[2] - view[0]) * 0.5 && g.y1 - g.y0 < m * 0.3 && (g.x0 < L.left - m * 3 || g.x1 > L.right + m * 3)));
  const drawn = await pageGraphics(page, frame);
  const rules = drawn.filter(furniture);
  const graphics = drawn.filter(g => !furniture(g));
  // A column's left edge; a turned page, with no known column, reads its own body's.
  const edgeOf = (col: number) => { const l = layoutOf(col).left; return isFinite(l) ? l : bodyExtent(lines, m).left; };
  const flow = new Set(lines.filter(ln => isFlow(ln, layoutOf(ln.col), edgeOf(ln.col))));
  const flowLines = [...flow];
  const isEq = (ln: Line, cont = false) => !flow.has(ln) && isEquationLine(ln, layoutOf(ln.col), cont);
  const chars = (ls: Line[]) => ls.reduce((s, ln) => s + ln.items.reduce((t, it) => t + it.str.trim().length, 0), 0);
  const owned = (r: Box) => lines.filter(ln => ownedShare(ln, r, m) >= 0.6);
  // A box (a frame, a shade) holding a listing or a prompt keeps its lines, as a code block. A
  // boxed paragraph reads as prose and stays text; so does a ruled table.
  const boxify = (b: Box) => {
    const all = owned(b);
    const own = all.filter(ln => !drop.has(ln));
    if (own.length < 2 || own.slice(0, 4).some((_, i) => detectTable(own, i))) return;
    if (!own.some(isMono) && !ragged(own, all)) return;
    blocks.push({ top: Math.max(...own.map(ln => ln.y + ln.height)), bottom: Math.min(...own.map(ln => ln.y)), md: fenced(own), col: colOf(b) });
    for (const ln of own) drop.add(ln);
  };
  // A lone frame or shade, text-sized, may be such a box.
  const boxes = () => {
    for (const g of graphics)
      if (g.rect && !g.image && g.x1 - g.x0 >= L.textWidth * 0.3 && g.y1 - g.y0 >= m * 2 && (g.x1 - g.x0) * (g.y1 - g.y0) < pageArea * 0.6) boxify(g);
  };
  // A rule or two and no math: nothing to cut, and no render.
  if (graphics.length < 3 && !graphics.some(g => g.image || !g.rect) && !lines.some(ln => isEq(ln))) { boxes(); return { figures, blocks, drop }; }

  onProgress(`Cutting figures: page ${p} / ${n}`);
  // The page renders turned to its frame. "print" intent: a display render paces itself with
  // requestAnimationFrame, which never fires in a hidden tab, so a headless run would wait forever.
  const vp = page.getViewport({ scale: RENDER_SCALE, rotation: frame.rot });
  const ctx = canvas.getContext("2d")!;
  canvas.width = Math.ceil(vp.width);
  canvas.height = Math.ceil(vp.height);
  ctx.fillStyle = "#fff";
  ctx.fillRect(0, 0, canvas.width, canvas.height);
  await page.render({ canvasContext: ctx, viewport: vp, canvas, background: "white", intent: "print" }).promise;
  const R: Render = { canvas, vp, frame };
  // The flow text leaves the ink item by item (a line clustered across the page can hold a
  // paragraph's words and a figure's labels both), each with its descender; a big delimiter or
  // a radical reads at its middle and hangs a full height either way.
  const erased: Box[] = [];
  for (const ln of flow) for (const it of ln.items) {
    if (!it.str.trim()) continue;
    const hangs = it.font.ext || /^√+$/.test(it.str.trim());
    erased.push({ x0: it.x - 0.5, x1: it.x + it.width + 0.5, y0: it.y - it.height * (hangs ? 1 : 0.35), y1: it.y + it.height * 1.05 });
  }
  for (const g of rules) erased.push({ x0: g.x0 - 1, y0: g.y0 - 1, x1: g.x1 + 1, y1: g.y1 + 1 });
  const ink = inkMask(R, erased);

  const inside = (g: Box, r: Box) => { const cx = (g.x0 + g.x1) / 2, cy = (g.y0 + g.y1) / 2; return cx >= r.x0 - m && cx <= r.x1 + m && cy >= r.y0 - m && cy <= r.y1 + m; };
  const classify = (r: Box): Region => {
    const own = owned(r);
    const gs = graphics.filter(g => inside(g, r));
    // An image's box is often clipped short of what it shows, so the ink says how wide the figure
    // is; the box only has to be more than an icon (a logo by a title).
    const image = gs.some(g => g.image && (g.x1 - g.x0) * (g.y1 - g.y0) >= m * m * 9);
    const drawing = gs.reduce((s, g) => s + (g.rect || g.image ? 0 : g.weight), 0);
    const ownChars = chars(own), flowChars = chars(own.filter(ln => flow.has(ln)));
    // Item dimensions are clamped: an outsized item (a vertical font reports its run as height)
    // would otherwise claim a block of the region.
    const textArea = own.reduce((s, ln) => s + ln.items.reduce((t, it) => t + Math.min(it.width, L.textWidth) * Math.min(it.height, 2 * m), 0), 0);
    const density = textArea / ((r.x1 - r.x0) * (r.y1 - r.y0));
    const captioned = lines.some(ln => ln.y <= r.y1 && ln.y >= r.y0 - m * 6 && FIG_CAPTION.test(captionText(ln, r, m)));
    // An equation's lines run past its ink cluster (a "y =" before a tall fraction, the number at
    // the margin): the equation lines set in the region's band count as its own.
    const band = lines.filter(ln => !flow.has(ln) && !own.includes(ln) && isEq(ln, true) && inCol(ln, r) && inBand(ln, r));
    const eqOwn = [...own.filter(ln => !flow.has(ln)), ...band];
    const math = eqOwn.some(ln => isEq(ln)) && chars(eqOwn.filter(ln => isEq(ln, true))) * 2 >= ownChars + chars(band);
    const big = r.y1 - r.y0 >= m * 3 && r.x1 - r.x0 >= L.textWidth * 0.15;
    const note = `n${gs.length}${image ? " img" : ""} draw${drawing} d${density.toFixed(2)} flow${ownChars ? (flowChars / ownChars).toFixed(2) : "-"}${captioned ? " cap" : ""}${math ? " math" : ""}`;
    const as = (kind: Kind, why: string, box: Box = r): Region => ({ ...box, kind, note, why, cap: captioned, took: 0 });
    const figure = (why: string) => as(big ? "figure" : "stray", why);
    // A page-sized image with body text laid over it is a background (a letterhead, a watermark).
    if (image && (r.x1 - r.x0) * (r.y1 - r.y0) > pageArea * 0.75 && own.filter(ln => isBody(ln, L)).length >= 8) return as("text", "background");
    // A figure caption claims what sits over it, however much text it carries (a diagram of
    // labelled boxes and arrows, a box of prompts).
    if (captioned) return figure("captioned");
    // A line-height region in the band of a text line (a QED box, a bullet drawn as a path, a
    // formula in a paragraph wrapped beside a figure) is the text's.
    if (r.y1 - r.y0 <= m * 1.5 && flowLines.some(ln => inCol(ln, r) && inBand(ln, r))) return as("text", "on a line");
    if (image) return figure("image");
    // Heavy drawing is a figure whatever its text (a legend's samples, a diagram's icons). Light
    // drawing yields to the text over it: among mostly flow text it is a box (a framed prompt, a
    // shaded theorem, a listing's shade), and among math it is the equation's (a big operator
    // drawn as paths).
    if (drawing >= 400) return figure("drawing");
    // A frame's own region (a box drawn around a theorem, a shaded definition) with prose in it
    // is a text box whatever else it holds: the display inside is cut on its own, and a listing
    // keeps its lines. Ten rules under braces would otherwise make it a diagram.
    const framed = gs.some(g => g.rect && !g.image && (g.x1 - g.x0) * (g.y1 - g.y0) < pageArea * 0.6
      && Math.abs(g.x0 - r.x0) <= m / 2 && Math.abs(g.x1 - r.x1) <= m / 2 && Math.abs(g.y0 - r.y0) <= m / 2 && Math.abs(g.y1 - r.y1) <= m / 2);
    if (framed && own.filter(ln => flow.has(ln) && isProse(ln)).length >= 2) return as("text", "framed");
    if (ownChars && flowChars * 2 >= ownChars) return as("text", "text");
    if (math) {
      // Small glyphs alone (a stray limit, a subscript) are no equation, nor is math set within a
      // prose line's band: inline math a floater merge missed.
      if (eqOwn.every(ln => ln.items.every(it => !it.str.trim() || it.height < m * 0.75))) return as("stray", "small");
      if (eqOwn.some(ln => ln.items.some(it => it.height >= m * 0.75) && flowLines.some(pl => !own.includes(pl) && pl.col === ln.col && isProse(pl) && Math.abs(pl.y - ln.y) < pl.height * 0.6))) return as("stray", "inline");
      let box: Box = r;
      for (const ln of eqOwn) {
        const e = extent(ln);
        box = union(box, { x0: e.x, x1: e.right, y0: ln.y - ln.height * 0.25, y1: ln.y + ln.height });
      }
      return as("equation", "equation", box);
    }
    if (drawing >= 60) return figure("drawing");
    // Dense text over lines with little drawing is a text box or a table, whatever its lines. A
    // text-dense grid is a table (a chart's aligned tick labels also detect as one, but sparsely).
    if (density >= 0.5 && own.length >= 2) return as("text", "text box");
    if (density >= 0.25 && own.slice(0, 4).some((_, i) => detectTable(own, i))) return as("text", "table");
    // Ten drawn elements (boxes, rules, arrows) with labels among them is a diagram.
    if (gs.length >= 10) return figure("diagram");
    return as("stray", "stray");
  };
  const regions = inkRegions(R, ink, Math.max(2, Math.round(m / 2))).map(classify);
  // A display set in a box: a shade's ink is one region, and a frame's takes what sits a cell from
  // its stroke, so the display inside would stay in the text. Its lines (a run, a line apart, of
  // lines that are neither flow text nor another region's, with a display's main row among them:
  // centered in the box, wide, or tagged at its right edge) make an equation region of their own,
  // the labels under its braces included, sized from its glyphs. A dense text box is a table more
  // often than not, and a table's math row is no display.
  for (const r of [...regions]) {
    if (r.kind !== "text" || !["text", "framed", "background"].includes(r.why)) continue;
    const loose = owned(r).filter(ln => !flow.has(ln) && !regions.some(o => o !== r && ownedShare(ln, o, m) >= 0.6)).sort((a, b) => b.y - a.y);
    const display = (ln: Line) => {
      const e = extent(ln);
      return isEq(ln) && (Math.abs((e.x - r.x0) - (r.x1 - e.right)) <= m * 2 || e.right - e.x >= (r.x1 - r.x0) * 0.5
        || (TAG_END.test(joinLineText(ln, true).trim()) && e.right >= r.x1 - m * 1.5));
    };
    for (let i = 0; i < loose.length;) {
      let j = i + 1;
      while (j < loose.length && loose[j - 1].y - loose[j].y <= m * 1.5) j++;
      const group = loose.slice(i, j);
      i = j;
      if (!group.some(display)) continue;
      const box = group.flatMap(ln => ln.items.filter(it => it.str.trim())
        .map(it => ({ x0: it.x, x1: it.x + it.width, y0: it.y - it.height * (it.font.ext ? 1 : 0.35), y1: it.y + it.height * 1.05 }))).reduce(union);
      regions.push({ ...box, kind: "equation", note: r.note, why: "boxed", cap: false, took: 0 });
    }
  }

  // A text line parts two regions. Joining equations: a body-sized line of words in text fonts,
  // in their column, in their band and owned by neither (a case's label sits beside it, not
  // between, and past the column's edge; a word tag between two rows is theirs). Joining figures:
  // a flow line between them and across them, running half the column in one run, or a caption (a
  // sentence in a diagram's box doesn't part its panels, nor does a row of panel titles, nor a
  // panel's own title, "(b) ..."). A captioned figure is parted by a caption alone: the caption
  // claims what sits over it. Joining strays: a flow line between them in their column.
  const parted = (a: Box, b: Box, kind: Kind, cap = false) => {
    const u = union(a, b);
    if (kind === "equation") return lines.some(ln => inCol(ln, u) && inBand(ln, u) && ln.height >= m * 0.9
      && fontShare(ln, f => f.math) < 0.5 && ln.items.some(it => /[A-Za-z]{2,}/.test(it.str)) && !TAG_ALONE.test(joinLineText(ln, true).trim())
      && ownedShare(ln, a, m) < 0.6 && ownedShare(ln, b, m) < 0.6);
    return flowLines.some(ln => {
      if (ln.y <= Math.min(a.y1, b.y1) || ln.y >= Math.max(a.y0, b.y0)) return false;
      if (kind !== "figure") return inCol(ln, u);
      const e = extent(ln);
      const text = joinLineText(ln, true).trim();
      if (/^\(?[a-z]\)\s/i.test(text) || overlapX(ln, u) <= 0) return false;
      return CAPTION.test(text) || (!cap && lineColumns(ln).length === 1 && e.right - e.x >= layoutOf(ln.col).textWidth * 0.5);
    });
  };
  // What a region takes in: a figure, or a stray a caption claims, the figures, equations, and
  // strays around it, and the text boxes too when captioned (a diagram drawn as tokens among its
  // own sentences); an equation the equations and strays; and strays each other. A stray that
  // took something is read again as one (a bar chart's bars, a scatter's points).
  const figureLike = (r: Region) => r.kind === "figure" || (r.kind === "stray" && r.cap);
  const takes = (a: Region, b: Region) =>
    figureLike(a) ? b.kind !== "text" || (a.cap && b.why !== "background" && b.why !== "table") : a.kind === "equation" && (b.kind === "equation" || b.kind === "stray");
  // Regions in different columns stay apart, whatever the gap: the gutter is narrow.
  const apart = (a: Box, b: Box) => gutter !== null && colOf(a) >= 0 && colOf(b) >= 0 && colOf(a) !== colOf(b);
  // A region with flow text a line over or under it is set in a paragraph (a formula in the text
  // wrapped beside a figure): nothing reaches sideways for it.
  const inText = (r: Box) => flowLines.some(ln => inCol(ln, r) && overlapX(ln, r) > 0.3
    && (Math.abs(ln.y - r.y1) <= m * 1.2 || Math.abs(ln.y - r.y0) <= m * 1.2));
  for (let merged = true; merged;) {
    merged = false;
    for (let i = 0; i < regions.length && !merged; i++) {
      for (let j = i + 1; j < regions.length; j++) {
        const a = regions[i], b = regions[j];
        if (apart(a, b)) continue;
        const dy = Math.max(a.y0 - b.y1, b.y0 - a.y1), dx = Math.max(a.x0 - b.x1, b.x0 - a.x1);
        const big = takes(a, b) ? a : takes(b, a) ? b : null;
        if (big) {
          const other = big === a ? b : a;
          const kind: Kind = figureLike(big) ? "figure" : big.kind;
          const reachY = kind === "figure" ? m * 3 : m * 2.5;
          // A captioned figure reaches further sideways: a diagram's tokens sit apart.
          const reachX = kind === "figure" ? (big.cap ? m * 4 : figureLike(other) ? m * 2 : m) : m;
          if (dy > reachY || dx > reachX || (dx > 0 && !figureLike(other) && inText(other)) || parted(a, b, kind, big.cap)) continue;
          regions[i] = big.kind === "stray" ? classify(union(a, b))
            : { ...union(a, b), kind: big.kind, note: big.note, why: big.why, cap: a.cap || b.cap, took: big.took + other.took + 1 };
        } else if (a.kind === "stray" && b.kind === "stray") {
          if (dy > m * 3 || dx > m || parted(a, b, "stray")) continue;
          regions[i] = classify(union(a, b));
        } else continue;
        regions.splice(j, 1);
        merged = true;
        break;
      }
    }
  }

  // Each region's fate, for the log: the cuts are the pipeline's opaque part.
  const fates: string[] = [];
  // A tag set between a display's rows ((CR) beside two rows of integrals) is in neither row's
  // band until the rows merge: the merged equation takes it in.
  for (const r of regions) {
    if (r.kind !== "equation") continue;
    for (const ln of lines) {
      if (flow.has(ln) || !inCol(ln, r) || !inBand(ln, r) || !TAG_ALONE.test(joinLineText(ln, true).trim())) continue;
      const e = extent(ln);
      Object.assign(r, union(r, { x0: e.x, x1: e.right, y0: ln.y - ln.height * 0.25, y1: ln.y + ln.height }));
    }
  }
  const eqs: string[] = [];
  const strays: string[] = [];
  for (const r of regions.sort((a, b) => b.y1 - a.y1)) {
    if (r.kind === "stray") { strays.push(`${at(r)} ${r.note} (${r.why})`); continue; }
    fates.push(`${at(r)} ${r.note}: ${r.kind} (${r.why}${r.took ? `, +${r.took}` : ""})`);
    if (r.kind === "text") continue;
    // The region's lines go with it; a caption stays, and so does flow text in an equation's box
    // (a short line of prose beside a tall radical's top): an equation has none of its own.
    const eq = r.kind === "equation";
    const own = owned(r).filter(ln => !isCaption(ln, r, m) && !(eq && flow.has(ln)));
    for (const ln of own) drop.add(ln);
    // The crop: the ink with a little room, short of the flow text over and under it (a
    // paragraph's descenders reach nearly to a display's superscripts); what of that text still
    // reaches in (a caption's ascenders past an image's lowest pixels, that line of prose by the
    // radical) is painted out.
    const rect: Box = { x0: r.x0 - 2, y0: r.y0 - 2, x1: r.x1 + 2, y1: r.y1 + 2 };
    const holes: Box[] = [];
    for (const ln of flow) for (const it of ln.items) {
      if (!it.str.trim() || it.x >= rect.x1 || it.x + it.width <= rect.x0) continue;
      if (it.y >= r.y1) rect.y1 = Math.min(rect.y1, Math.max(r.y1, it.y - it.height * 0.25));
      else if (it.y < r.y0) rect.y0 = Math.max(rect.y0, Math.min(r.y0, it.y + it.height * 0.8));
      else if (!eq) continue;
      // With a point around: an italic's overhang and a glyph's antialiasing run past its box.
      holes.push({ x0: it.x - 1.5, x1: it.x + it.width + 1.5, y0: it.y - it.height * 0.3, y1: it.y + it.height * 1.05 });
    }
    const toPage = (b: Box) => boxUnder(frame.from, b.x0, b.y0, b.x1, b.y1);
    const png = await cropFigure(canvas, vp, toPage(rect), holes.map(toPage));
    if (!png) continue;
    const k = figures.filter(f => isEquationFile(f) === eq).length + 1;
    let alt: string;
    let eqn: Figure["eq"];
    if (eq) {
      // The equation's numbers: each line ending in "(n)" at the right edge (a superscript row can
      // end in "(0)", short of it); consecutive equations share a crop.
      const right = Math.max(...own.map(ln => extent(ln).right));
      const tags = own.filter(ln => extent(ln).right >= right - m)
        .map(ln => joinLineText(ln, true).match(/\((\d+[a-z]?)\)\s*$/)?.[1]).filter((t): t is string => !!t);
      alt = tags.length ? `Equation${tags.length > 1 ? "s" : ""} (${tags.join("), (")})` : "Equation";
      eqn = { tags, text: own.map(ln => joinLineText(ln, true).trim()).join(" ") };
      eqs.push(JSON.stringify(own.map(ln => joinLineText(ln).trim()).join(" / ")));
    } else alt = captionFor(lines, L, rect, "");
    figures.push({ top: rect.y1, bottom: rect.y0, png, file: `figures/page-${pad(p)}-${eq ? "eq" : "fig"}-${k}.png`, alt, col: colOf(rect), eq: eqn });
  }
  // Panels side by side share the caption set under one of them; a figure with none is named
  // for its page.
  const figs = figures.filter(f => !isEquationFile(f));
  const captioned = figs.filter(f => f.alt);
  figs.forEach((f, i) => {
    if (f.alt) return;
    const beside = captioned.find(o => Math.min(f.top, o.top) - Math.max(f.bottom, o.bottom) >= Math.min(f.top - f.bottom, o.top - o.bottom) * 0.5);
    f.alt = beside?.alt || `Figure ${i + 1} on page ${p}`;
  });
  boxes();
  if (strays.length) fates.push(`stray: ${strays.join(", ")}`);
  if (rules.length) fates.push(`furniture: ${rules.map(at).join(", ")}`);
  tb.log(`[fig] page ${p}: ${fates.join("; ")}`);
  if (eqs.length) tb.log(`[eq] page ${p}: ${eqs.join(", ")}`);
  return { figures, blocks, drop };
}

function pageToMarkdown(lines: Line[], L: Layout, layoutOf: (col: number) => Layout, blocks: Block[], drop: Set<Line>): string {
  // Text inside a figure, a box, or an equation travels with its block, not the text.
  lines = lines.filter(ln => !drop.has(ln));
  // A page number: a lone number as the page's first or last line.
  const texty = lines.filter(ln => ln.items.some(it => it.str.trim()));
  const pageNos = [texty[0], texty[texty.length - 1]].filter(ln => ln && isPageNumber(ln));
  lines = lines.filter(ln => !pageNos.includes(ln));
  // Reading order: strips down the page, cut where a line or a block spans the columns; a strip's
  // columns read left then right, each as a page of its own. A strip with a column empty is no
  // two-column region (a spanning paragraph's short last line) and reads on with its neighbours.
  type Unit = { y: number; col: number; line?: Line; block?: Block };
  type Strip = { spanning: boolean; cols: Unit[][] };
  const units: Unit[] = [
    ...lines.map(ln => ({ y: ln.y, col: ln.col, line: ln })),
    ...blocks.map(b => ({ y: b.bottom, col: b.col, block: b })),
  ].sort((a, b) => b.y - a.y);
  const strips: Strip[] = [];
  for (const u of units) {
    const spanning = u.col < 0;
    let strip = strips[strips.length - 1];
    if (!strip || strip.spanning !== spanning) strips.push(strip = { spanning, cols: [[], []] });
    strip.cols[spanning ? 0 : u.col].push(u);
  }
  const merged: Strip[] = [];
  for (const s of strips) {
    const spanning = s.spanning || s.cols.some(c => !c.length);
    const last = merged[merged.length - 1];
    if (spanning && last?.spanning) last.cols[0].push(...s.cols[0], ...s.cols[1]);
    else merged.push(spanning ? { spanning, cols: [[...s.cols[0], ...s.cols[1]], []] } : s);
  }
  let out = "";
  for (const s of merged)
    s.cols.forEach((us, c) => {
      if (us.length) out += columnToMarkdown(us.flatMap(u => u.line ? [u.line] : []), layoutOf(s.spanning ? -1 : c), us.flatMap(u => u.block ? [u.block] : []));
    });
  return out;
}

// A column's lines (top first) and the blocks in it, as markdown.
function columnToMarkdown(lines: Line[], L: Layout, blocks: Block[]): string {
  const medH = L.medH;
  const headed = lines.some(ln => CONTENTS_HEAD.test(joinLineText(ln, true).trim()));
  const pending = [...blocks].sort((a, b) => b.top - a.top);
  const emitBlocksAbove = (y: number) => {
    let s = "";
    while (pending.length && pending[0].bottom > y) s += pending.shift()!.md;
    return s;
  };
  let out = "";
  let i = 0;
  while (i < lines.length) {
    if (lines[i].items.every(it => !it.str.trim())) { i++; continue; }
    out += emitBlocksAbove(lines[i].y);

    if (startsListing(lines, i)) {
      const j = monoRunEnd(lines, i);
      out += "\n" + fenced(lines.slice(i, j));
      i = j;
      continue;
    }

    const toc = contentsRun(lines, i, medH, headed);
    if (toc) {
      out += "\n" + toc.md + "\n";
      i = toc.end;
      continue;
    }

    const table = detectTable(lines, i);
    if (table) {
      out += "\n" + renderTable(table.rows) + "\n";
      i = table.end;
      continue;
    }

    const line = lines[i];
    const text = joinLineText(line).trim();
    if (!text) { i++; continue; }

    const h = classifyHeading(line, L);
    if (h) {
      // A wrapped heading continues on same-level lines a line apart.
      let ht = unstyled(text);
      let j = i + 1;
      while (j < lines.length && classifyHeading(lines[j], L) === h && lines[j - 1].y - lines[j].y <= lines[j - 1].height * 1.6) {
        const t2 = joinLineText(lines[j]).trim();
        if (!t2) break;
        ht = joinWrapped(ht, unstyled(t2));
        j++;
      }
      out += `\n${hashes(h)} ${ht}\n\n`;
      i = j;
      continue;
    }

    const ch = capsHeading(text, line, lines[i - 1], L);
    if (ch) {
      out += `\n${hashes(ch)} ${unstyled(text)}\n\n`;
      i++;
      continue;
    }

    const lst = getListMarker(text);
    if (lst) {
      // An item's wrapped lines start past its marker, a line apart.
      let item = lst.rest;
      let j = i + 1;
      while (j < lines.length) {
        const ln = lines[j];
        const t2 = joinLineText(ln).trim();
        if (!t2 || getListMarker(t2) || classifyHeading(ln, L) || startsListing(lines, j) || detectTable(lines, j)) break;
        if (lines[j - 1].y - ln.y > lines[j - 1].height * 1.6 || Math.abs(ln.height - line.height) > 0.5) break;
        if (extent(ln).x <= extent(line).x + medH * 0.3) break;
        item = joinWrapped(item, t2);
        j++;
      }
      out += lst.kind === "ul" ? `- ${item}\n` : `1. ${item}\n`;
      i = j;
      const nxt = lines[i];
      if (!nxt || !getListMarker(joinLineText(nxt).trim())) out += "\n";
      continue;
    }

    // Paragraph: gather following body lines.
    const run = [line];
    const texts = [text];
    const beside = (ln: Line) => blocks.some(b => ln.y <= b.top + medH * 1.5 && ln.y >= b.bottom - medH * 1.5);
    let j = i + 1;
    while (j < lines.length) {
      const ln = lines[j];
      const t2 = joinLineText(ln).trim();
      if (!t2) break;
      if (classifyHeading(ln, L)) break;
      if (startsListing(lines, j)) break;
      if (getListMarker(t2)) break;
      if (capsHeading(t2, ln, lines[j - 1], L)) break;
      const gap = lines[j - 1].y - ln.y;
      if (gap > lines[j - 1].height * 1.6) break;
      // A font-size change ends the paragraph: keeps footnotes off the body above them.
      if (Math.abs(ln.height - lines[j - 1].height) > 0.5) break;
      if (detectTable(lines, j)) break;
      // A first-line indent after a full line starts a paragraph, where the style leaves no space.
      const prev = extent(lines[j - 1]);
      if (!beside(ln) && extent(ln).x > prev.x + medH && prev.right >= Math.max(...run.map(r => extent(r).right)) - medH * 2) break;
      run.push(ln);
      texts.push(t2);
      j++;
    }
    // A line that stops short of the block's right edge with text under it was broken on purpose
    // (an author block, a listing), and the break stays, as markdown's trailing backslash: justified
    // prose only stops short on its last line, or beside a figure. A centered block's lines stop
    // short by half as much each side, and a narrower justified block (an abstract) is symmetric
    // too but keeps one left edge, so a centered line breaks when the next starts elsewhere.
    const blockRight = Math.max(...run.map(ln => extent(ln).right));
    const keeps = (k: number) => (!beside(run[k]) && extent(run[k]).right < blockRight - L.textWidth * 0.2)
      || (centered(run[k], L) && Math.abs(extent(run[k]).x - extent(run[k + 1]).x) > medH * 0.8);
    let paraText = texts[0];
    for (let k = 1; k < texts.length; k++)
      paraText = keeps(k - 1) ? `${paraText}\\\n${texts[k]}` : joinWrapped(paraText, texts[k]);
    // A line opening with "#" or ">" (a listing's comment, a quote) would render as markup.
    out += paraText.replace(/^([#>])/gm, "\\$1") + "\n\n";
    i = j;
  }
  out += emitBlocksAbove(-Infinity);
  return out;
}

const padOf = (total: number) => (i: number) =>
  String(i).padStart(Math.max(3, String(total).length), "0");

interface Conversion {
  srcName: string;
  // Where the PDF came from: a URL, a path, or a dropped file's name (all a browser tells).
  source: string;
  stem: string;
  pages: number;
  figures: FigureFile[];
  md: string;
  totalChars: number;
  isScan: boolean;
  // The text column's width in points: what a figure's size on the page is read against.
  textWidth: number;
  // The equations that carry their LaTeX, and the arXiv id (with its version) the paper reads
  // as, or "".
  latex: number;
  arxiv: string;
}

// Figure files by kind: a display equation is cut like a figure and named for it.
const isEquationFile = (f: FigureFile) => f.file.includes("-eq-");

function buildDocument(src: Src, pageMds: string[], figures: FigureFile[], isScan: boolean, textWidth: number, latex: number, arxiv: string): string {
  const n = pageMds.length;
  const nEqs = figures.filter(isEquationFile).length;
  const nFigs = figures.length - nEqs;
  const cut = [nFigs && `${nFigs} ${nFigs === 1 ? "figure" : "figures"}`, nEqs && `${nEqs} display ${nEqs === 1 ? "equation" : "equations"}`].filter(Boolean).join(" and ");
  let out = `# Markdown of ${src.name}\n\nSource: ${src.source}\n\n`;
  out += `${n} ${n === 1 ? "page" : "pages"}. The text below is in reading order with \`--- Page N ---\` markers (a two-column page reads column by column, so a paragraph can break where a column ends); headings, lists, and tables are inferred from layout. `;
  out += isScan
    ? `This PDF has no text layer (a scan), so each page is a figure: `
    : `${cut || "No figures"} ${figures.length === 1 ? "was" : "were"} cut out of the pages as PNG files under figures/ (${RENDER_SCALE * 72} DPI; the text column is ${Math.round(textWidth)} pt wide), `;
  out += `referenced where ${figures.length === 1 ? "it appears" : "they appear"} (\`![caption](figures/...)\`). `;
  out += latex
    ? `${latex} of the equations carry their LaTeX from arXiv:${arxiv} in $$ blocks under the image. Open an image to read a chart, a diagram, or an equation without one.\n\n`
    : `Open one to read a chart, a diagram, or an equation.\n\n`;
  for (let i = 0; i < n; i++) {
    out += `--- Page ${i + 1} ---\n\n`;
    out += (pageMds[i] || `(nothing on this page)`) + "\n\n";
  }
  return out.replace(/\n{3,}/g, "\n\n").trim() + "\n";
}

const safeStem = (name: string) =>
  name.replace(/\.pdf$/i, "").replace(/[^\w.-]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase() || "document";

// An arXiv paper's id, with its version when given ("2510.21150v1", "hep-th/9711200"): off the URL
// the PDF came from (abs, pdf, or html, with or without .pdf, export.arxiv.org too), or off the
// stamp arXiv sets up page 1's margin ("arXiv:2510.21150v1 [cs.CL] 24 Oct 2025").
function arxivIdOf(s: string): string | null {
  const m = s.match(/(?:arxiv\.org\/(?:abs|pdf|html)\/|arXiv:)(\d{4}\.\d{4,5}|[a-z-]+(?:\.[A-Z]{2})?\/\d{7})(v\d+)?/);
  return m ? m[1] + (m[2] ?? "") : null;
}

// A line of an arXiv paper's display math, off arxiv.org's HTML rendering: its number, if any,
// its TeX by cell (LaTeXML keeps each <math>'s in its alttext, and cuts an aligned row at its &
// into one cell each, prefixed \displaystyle, empty where the source left it), and the group it
// belongs to (an align's rows share one; a single equation is its own). An align numbers its
// rows; an equation set as a split or an aligned numbers its group as a whole, and that number is
// the group's.
interface TexGroup { tag: string | null }
interface TexRow { tag: string | null; cells: string[]; group: TexGroup }

// The paper's display math in document order: every equation row, whether set as a table row or
// as a span (an equation in a theorem's statement). Null when the paper has no HTML: that 404
// carries no CORS header, so the fetch rejects rather than reporting it.
async function arxivLatex(id: string): Promise<TexRow[] | null> {
  let html: string;
  try {
    const r = await fetch(`https://arxiv.org/html/${id}`);
    if (!r.ok) return null;
    html = await r.text();
  } catch { return null; }
  const rows: TexRow[] = [];
  const groups = new Map<Element, TexGroup>();
  const numberOf = (el: Element | null) => el?.textContent?.match(/\(([^()\s]+)\)/)?.[1] ?? null;
  // Vertical space is the page's business (a "\vskip-5.0pt" closing an equation), and a command
  // KaTeX lacks; so is a cross-reference, which the rendering resolves to its number, one
  // .ltx_ref_tag per reference in order (\eqref sets the parentheses, \ref does not).
  const texOf = (el: Element | null) => {
    if (!el) return "";
    const nums = Array.from(el.querySelectorAll(".ltx_ref_tag")).map(r => r.textContent!.trim());
    let i = 0;
    return el.getAttribute("alttext")!.replace(/^\\displaystyle/, "")
      .replace(/\\(?:vskip\s*-?[\d.]+\s*[a-z]{2}|vspace\*?\{[^{}]*\}|(?:small|med|big)skip\b)/g, "")
      .replace(/\\(eq)?ref\{[^{}]*\}/g, (_, eq) => { const n = nums[i++] ?? "??"; return eq ? `\\text{(${n})}` : `\\text{${n}}`; })
      .replace(/\s+/g, " ").trim();
  };
  for (const row of Array.from(new DOMParser().parseFromString(html, "text/html").querySelectorAll(".ltx_equation.ltx_eqn_row"))) {
    // An aligned row's cells are its td.ltx_td; a lone equation has none, and one math.
    const tds = Array.from(row.querySelectorAll("td.ltx_td"));
    const cells = tds.length ? tds.map(td => texOf(td.querySelector("math[alttext]"))) : Array.from(row.querySelectorAll("math[alttext]")).map(texOf);
    if (!cells.some(Boolean)) continue;
    const g = row.closest(".ltx_equationgroup") ?? row;
    if (!groups.has(g)) groups.set(g, { tag: numberOf(g.querySelector(".ltx_tag_equationgroup")) });
    rows.push({ tag: numberOf(row.querySelector(".ltx_tag_equation")), cells, group: groups.get(g)! });
  }
  return rows;
}

// Some math as a bag of its letters, digit runs, and Greek: a crop's text layer and the LaTeX of
// the same equation hold the same ones, whatever order a fraction's rows read in. Greek variants
// fold together (which of ϕ and φ a font's glyph maps to is the font's business).
const GREEK = new Map(Object.entries({ alpha: "α", beta: "β", gamma: "γ", delta: "δ", epsilon: "ε", varepsilon: "ε", zeta: "ζ", eta: "η", theta: "θ", vartheta: "θ", iota: "ι", kappa: "κ", lambda: "λ", mu: "μ", nu: "ν", xi: "ξ", pi: "π", varpi: "π", rho: "ρ", varrho: "ρ", sigma: "σ", varsigma: "σ", tau: "τ", upsilon: "υ", phi: "φ", varphi: "φ", chi: "χ", psi: "ψ", omega: "ω", Gamma: "Γ", Delta: "Δ", Theta: "Θ", Lambda: "Λ", Xi: "Ξ", Pi: "Π", Sigma: "Σ", Upsilon: "Υ", Phi: "Φ", Psi: "Ψ", Omega: "Ω" }));
const GREEK_FOLD: Record<string, string> = { "ϕ": "φ", "ϵ": "ε", "ϑ": "θ", "ϖ": "π", "ϱ": "ρ", "ς": "σ" };
const OPERATORS = new Set(["ln", "log", "lg", "exp", "min", "max", "sin", "cos", "tan", "sec", "csc", "cot", "arcsin", "arccos", "arctan", "sinh", "cosh", "tanh", "lim", "sup", "inf", "det", "dim", "arg", "Pr", "gcd", "argmax", "argmin", "deg", "ker", "mod", "bmod", "pmod"]);
function mathBag(s: string): Map<string, number> {
  const bag = new Map<string, number>();
  for (const t of s.replace(/[ϕϵϑϖϱς]/g, c => GREEK_FOLD[c]).match(/[A-Za-z]|\d+|[α-ωΑ-Ω]/g) || []) bag.set(t, (bag.get(t) || 0) + 1);
  return bag;
}
// The LaTeX as the text layer shows it: Greek and operator names as their glyphs, other commands
// gone, and gone with their arguments the ones whose arguments never print (an environment's
// name, a label).
const texText = (tex: string) => tex
  .replace(/\\(?:begin|end|label|ref|eqref|tag|hspace|vspace|phantom|hphantom|vphantom)\*?\{[^{}]*\}(?:\{[^{}]*\})?/g, " ")
  .replace(/\\([A-Za-z]+)/g, (_, c) => GREEK.get(c) ?? (OPERATORS.has(c) ? ` ${c} ` : " "));
// Dice's coefficient of two bags.
function dice(a: Map<string, number>, b: Map<string, number>): number {
  let both = 0, na = 0, nb = 0;
  for (const [t, k] of a) { na += k; both += Math.min(k, b.get(t) || 0); }
  for (const k of b.values()) nb += k;
  return na + nb ? (2 * both) / (na + nb) : 0;
}

// A crop's text layer and its LaTeX agree from here up. Below it the LaTeX is left out: a number
// LaTeXML counted differently from the PDF would pass another equation off as this one.
const LATEX_MATCH = 0.6;

// A crop's LaTeX ($$ blocks, or none) and the log's account of it: the score, the count when
// lines were added, and on a rejection the number whose LaTeX fits best instead. at: the rows
// matched.
interface Latex { md: string; note: string; at?: [number, number] }

const tex = (rs: TexRow[]) => rs.map(r => r.cells.join(" ")).join(" ");
// A row's number: its own, else its group's.
const numOf = (r: TexRow) => r.tag ?? r.group.tag;
// How well a crop's text (its numbers out) agrees with some rows' LaTeX.
const cropBag = (f: Figure) => mathBag(f.eq!.text.replace(/\(\d+[a-z]?\)/g, ""));
const agree = (bag: Map<string, number>, rs: TexRow[]) => dice(bag, mathBag(texText(tex(rs))));

// Rows a to b as $$ blocks. A display numbering its rows is a block per row, each numbered one
// carrying its \tag; one numbered as a whole, or not at all, is one block of its rows aligned at
// their cells' seams, the \tag after.
function texBlocks(rows: TexRow[], a: number, b: number): string {
  const byRow = new Set(rows.filter(r => r.tag).map(r => r.group));
  let md = "";
  for (let i = a; i <= b;) {
    const g = rows[i].group;
    let j = i;
    if (!byRow.has(g)) while (j < b && rows[j + 1].group === g) j++;
    const rs = rows.slice(i, j + 1);
    const body = !byRow.has(g) && (rs.length > 1 || rs[0].cells.length > 1)
      ? `\\begin{aligned}${rs.map(r => r.cells.join(" & ")).join(" \\\\ ")}\\end{aligned}` : tex(rs);
    const tag = g.tag ?? rows[i].tag;
    md += `$$\n${body}${tag ? ` \\tag{${tag}}` : ""}\n$$\n\n`;
    i = j + 1;
  }
  return md;
}

// A numbered crop's LaTeX, when its text agrees with it: its numbers' rows, and around them
// whichever untagged rows of their groups the text agrees with best (a derivation numbers its last
// line only, and the crop holds all of it). Nothing when the HTML lacks the number.
function latexBlocks(f: Figure, rows: TexRow[]): Latex {
  const tags = f.eq!.tags.filter(t => rows.some(r => numOf(r) === t));
  if (!tags.length) return { md: "", note: "" };
  const idx = rows.flatMap((r, i) => { const n = numOf(r); return n !== null && tags.includes(n) ? [i] : []; });
  const bag = cropBag(f);
  const score = (a: number, b: number) => agree(bag, rows.slice(a, b + 1));
  const lo = Math.min(...idx), hi = Math.max(...idx);
  const untaggedIn = (i: number, g: TexGroup) => i >= 0 && i < rows.length && !rows[i].tag && rows[i].group === g;
  let best = { a: lo, b: hi, s: score(lo, hi) };
  for (let a = lo; a === lo || untaggedIn(a, rows[lo].group); a--)
    for (let b = hi; b === hi || untaggedIn(b, rows[hi].group); b++) {
      const s = score(a, b);
      if (s > best.s) best = { a, b, s };
    }
  let note = `(${tags.join("), (")}) ${best.s.toFixed(2)}`;
  if (best.b - best.a > hi - lo) note += ` over ${best.b - best.a + 1} lines`;
  if (best.s < LATEX_MATCH) {
    const other = rows.filter(r => numOf(r) && !tags.includes(numOf(r)!)).map(r => [numOf(r), agree(bag, [r])] as const).sort((x, y) => y[1] - x[1])[0];
    return { md: "", note: note + ` rejected${other ? `, best (${other[0]}) ${other[1].toFixed(2)}` : ""}` };
  }
  return { md: texBlocks(rows, best.a, best.b), note, at: [best.a, best.b] };
}

// The pairing of two sequences, in order, that agrees best over all: a pair scoring under
// LATEX_MATCH is no pair, and an item can go unpaired. Each pair with its score.
function pairUp(n: number, m: number, w: (i: number, j: number) => number): [number, number, number][] {
  const W = Array.from({ length: n }, (_, i) => Array.from({ length: m }, (_, j) => w(i, j)));
  const best = Array.from({ length: n + 1 }, () => new Float64Array(m + 1));
  for (let i = 1; i <= n; i++) for (let j = 1; j <= m; j++) {
    const s = W[i - 1][j - 1];
    best[i][j] = Math.max(best[i - 1][j], best[i][j - 1], s >= LATEX_MATCH ? best[i - 1][j - 1] + s : 0);
  }
  const pairs: [number, number, number][] = [];
  for (let i = n, j = m; i > 0 && j > 0;) {
    if (best[i][j] === best[i - 1][j]) i--;
    else if (best[i][j] === best[i][j - 1]) j--;
    else { pairs.push([i - 1, j - 1, W[i - 1][j - 1]]); i--; j--; }
  }
  return pairs.reverse();
}

// The LaTeX of the document's equation crops, by crop. A numbered crop's is its numbers' rows,
// when the HTML knows the number (one it doesn't is text). An unnumbered crop's is found by its
// place: between two numbered crops, the unnumbered crops and the HTML's unnumbered displays run
// in the same order, so they pair off in order by how well each crop's text agrees with a
// display's LaTeX, a display the pipeline kept as text or a crop the HTML has no display for going
// unpaired. A display tagged with a word ((Curve), (IC)), whose crop carries no number, pairs off
// with the unnumbered ones and keeps its \tag. The crops read in page order, a two-column
// page's left column before its right.
function latexFor(pages: Figure[][], rows: TexRow[]): Map<Figure, Latex> {
  const out = new Map<Figure, Latex>();
  const crops = pages.flatMap(figs => figs.filter(f => f.eq).sort((a, b) => Math.max(0, a.col) - Math.max(0, b.col) || b.top - a.top));
  const numbered = (f: Figure) => f.eq!.tags.some(t => rows.some(r => numOf(r) === t));
  // The displays as row ranges, and each row's.
  const units: [number, number][] = [];
  const unitOf: number[] = [];
  rows.forEach((r, i) => {
    if (i && rows[i - 1].group === r.group) units[units.length - 1][1] = i; else units.push([i, i]);
    unitOf.push(units.length - 1);
  });
  const rowsOf = (u: number) => rows.slice(units[u][0], units[u][1] + 1);
  // A number as cutPage reads one off a crop's line end.
  const numeric = (t: string | null) => t !== null && /^\d+[a-z]?$/.test(t);
  const unnumbered = (u: number) => rowsOf(u).every(r => !numeric(r.tag) && !numeric(r.group.tag));
  const bags = units.map((_, u) => mathBag(texText(tex(rowsOf(u)))));
  // The numbered crops first: those whose LaTeX was found anchor the rest, in order.
  const anchors = [{ c: -1, lo: -1, hi: -1 }];
  crops.forEach((f, c) => {
    if (!numbered(f)) return;
    const l = latexBlocks(f, rows);
    out.set(f, l);
    if (!l.at) return;
    const lo = unitOf[l.at[0]], hi = unitOf[l.at[1]];
    if (lo >= anchors[anchors.length - 1].hi) anchors.push({ c, lo, hi });
  });
  anchors.push({ c: crops.length, lo: units.length, hi: units.length });
  for (let k = 0; k + 1 < anchors.length; k++) {
    const cs: number[] = [], us: number[] = [];
    for (let c = anchors[k].c + 1; c < anchors[k + 1].c; c++) if (!numbered(crops[c])) cs.push(c);
    for (let u = anchors[k].hi + 1; u < anchors[k + 1].lo; u++) if (unnumbered(u)) us.push(u);
    const cropBags = cs.map(c => cropBag(crops[c]));
    const pairs = pairUp(cs.length, us.length, (i, j) => dice(cropBags[i], bags[us[j]]));
    cs.forEach((c, i) => {
      const p = pairs.find(([pi]) => pi === i);
      if (p) { out.set(crops[c], { md: texBlocks(rows, units[us[p[1]]][0], units[us[p[1]]][1]), note: `(unnumbered) ${p[2].toFixed(2)}`, at: units[us[p[1]]] }); return; }
      const near = us.length ? `, best ${Math.max(...us.map(u => dice(cropBags[i], bags[u]))).toFixed(2)}` : "";
      out.set(crops[c], { md: "", note: `(unnumbered) none${near}` });
    });
  }
  return out;
}

// The equations of a document that carry their LaTeX: the image references with a $$ block under.
const latexCount = (md: string) => (md.match(/^!\[Equation[^\n]*\n\n\$\$$/gm) || []).length;

async function processBytes(
  src: Src,
  onProgress: (s: string) => void
): Promise<Conversion> {
  const pdf = await pdfjs.getDocument({
    data: src.bytes.slice(), // the worker detaches the buffer it is given; keep ours
    cMapUrl: tb.proxy(`https://unpkg.com/pdfjs-dist@${pdfjs.version}/cmaps/`),
    cMapPacked: true,
    standardFontDataUrl: tb.proxy(`https://unpkg.com/pdfjs-dist@${pdfjs.version}/standard_fonts/`),
  }).promise;
  try {
    const n = pdf.numPages;
    const pad = padOf(n);
    const pages: Page[] = [];
    const fontNames = new Set<string>();
    let stamp: string | null = null;
    for (let p = 1; p <= n; p++) {
      onProgress(`Reading text: page ${p} / ${n}`);
      const page = await pdf.getPage(p);
      // The operator list first: fetching it is what loads the page's fonts, whose names carry
      // weight, slant, and pitch. The figure pass reads the same cached list.
      await page.getOperatorList();
      const tc = await page.getTextContent();
      const frame = pageFrame(tc.items, page.view);
      const { items, margin } = extractItems(tc, pageFonts(page, tc, fontNames), frame);
      if (!stamp) stamp = margin.map(arxivIdOf).find(Boolean) ?? null;
      pages.push({ lines: groupIntoLines(items), frame });
    }
    tb.log(`[fonts] ${[...fontNames].join(", ")}`);
    // The stamp names the version these bytes are; a URL may not. The fetch is the page's own,
    // from the user's address: the interactive use arXiv's access policy puts first, with no
    // typebulb server in between (its terms forbid one).
    const arxiv = stamp ?? arxivIdOf(src.source) ?? "";
    let tex: TexRow[] | null = null;
    if (arxiv) {
      onProgress("Fetching LaTeX from arXiv...");
      tex = await arxivLatex(arxiv);
      tb.log(tex ? `[arxiv] ${arxiv}: ${tex.length} lines of display math in its HTML, ${new Set(tex.map(r => r.tag ?? r.group.tag).filter(Boolean)).size} numbers` : `[arxiv] no HTML for ${arxiv}`);
    }
    const turned = pages.map((pg, i) => pg.frame.rot ? `${i + 1} (${pg.frame.rot}°)` : "").filter(Boolean);
    if (turned.length) tb.log(`[frame] turned pages: ${turned.join(", ")}`);
    const layout = measureLayout(pages);
    // A page's body size is the document's where the page sets a few lines in it or has little
    // text at all, else its own: a page of transcripts set small, or a figure page whose caption
    // is its largest text. The column extents stay the document's.
    const medHs = pages.map(pg => {
      const chars = (f: (it: TextItem) => boolean) =>
        pg.lines.flatMap(ln => ln.items).filter(f).reduce((s, it) => s + it.str.trim().length, 0);
      const inDoc = chars(it => Math.abs(it.height - layout.medH) <= layout.medH * 0.01);
      return (inDoc < 200 && chars(() => true) >= 300 && bodyHeight(pg.lines)) || layout.medH;
    });
    const resized = medHs.flatMap((h, i) => h !== layout.medH ? [`${i + 1} (${h.toFixed(1)})`] : []);
    tb.log(`[body] ${layout.medH.toFixed(1)}pt${resized.length ? `; pages measuring otherwise: ${resized.join(", ")}` : ""}`);
    // Each page's gutter, and the document's where three or more of its pages (in its frame) agree
    // within a line height: it then applies to a page too sparse or too tabular to show its own.
    const gutters = pages.map(pg => findGutter(pg.lines, layout.medH));
    const found = pages.flatMap((pg, i) => pg.frame.rot === layout.rot && gutters[i] !== null ? [gutters[i]!] : []).sort((a, b) => a - b);
    const mid = found[found.length >> 1];
    const docGutter = found.filter(g => Math.abs(g - mid) <= layout.medH).length >= 3 ? mid : null;
    const twoCol = gutters.flatMap((g, i) => g !== null ? [i + 1] : []);
    if (twoCol.length) tb.log(`[columns] two-column pages (${twoCol.length} of ${n}): ${twoCol.join(", ")}${docGutter !== null ? `; the document's gutter at x=${docGutter.toFixed(0)}` : ""}`);

    // The cuts: a page is rendered only when it draws something or sets math.
    const canvas = document.createElement("canvas");
    const cut: { cuts: Cuts; lines: Line[]; L: Layout; layoutOf: (col: number) => Layout }[] = [];
    for (let p = 1; p <= n; p++) {
      const page = await pdf.getPage(p);
      const { frame } = pages[p - 1];
      const medH = medHs[p - 1];
      // A page turned the other way (a wide table) has no known column: its edges are the page's.
      const L: Layout = frame.rot === layout.rot ? { ...layout, medH } : { ...layout, medH, left: -Infinity, right: Infinity };
      const gutter = frame.rot === layout.rot && docGutter !== null ? docGutter : gutters[p - 1];
      const { lines, layoutOf } = columnize(pages[p - 1].lines, gutter, L);
      cut.push({ cuts: await cutPage({ page, p, n, pad, lines, frame, L, layoutOf, gutter }, canvas, onProgress), lines, L, layoutOf });
    }
    const figures = cut.flatMap(c => c.cuts.figures);
    // The LaTeX waits for every page: an unnumbered crop is placed among the numbered ones.
    const latexOf = tex ? latexFor(cut.map(c => c.cuts.figures), tex) : new Map<Figure, Latex>();
    const pageMds = cut.map(({ cuts, lines, L, layoutOf }, i) => {
      const notes: string[] = [];
      const figBlocks = cuts.figures.map(f => {
        const l = latexOf.get(f);
        if (l?.note) notes.push(l.note);
        return { top: f.top, bottom: f.bottom, md: `![${f.alt}](${f.file})\n\n${l?.md ?? ""}`, col: f.col };
      });
      if (notes.length) tb.log(`[latex] page ${i + 1}: ${notes.join("; ")}`);
      return pageToMarkdown(lines, L, layoutOf, [...figBlocks, ...cuts.blocks], cuts.drop).replace(/\n{3,}/g, "\n\n").trim();
    });
    const totalChars = pageMds.reduce((s, m) => s + m.length, 0);
    const isScan = totalChars < n * 25;
    const latex = latexCount(pageMds.join("\n"));
    const md = buildDocument(src, pageMds, figures, isScan, layout.textWidth, latex, arxiv);
    return { srcName: src.name, source: src.source, stem: safeStem(src.name), pages: n, figures, md, totalChars, isScan, textWidth: layout.textWidth, latex, arxiv };
  } finally {
    pdf.destroy();
  }
}

// A conversion's folder under the bulb's, and the absolute path of a folder under it.
const folderOf = (stem: string) => `converted/${stem}`;
function absPath(rel: string): string {
  const sep = tb.dir.includes("\\") ? "\\" : "/";
  return tb.dir + sep + rel.split("/").join(sep);
}

// Writes the result under the bulb's folder as converted/<stem>/ and returns that folder's absolute path.
async function saveToProject(d: Conversion): Promise<string> {
  const dir = folderOf(d.stem);
  await tb.fs.write(`${dir}/document.md`, d.md);
  for (const f of d.figures) await tb.fs.write(`${dir}/${f.file}`, f.png);
  const abs = absPath(dir);
  tb.log(`[convert] ${d.srcName}: ${d.pages} pages, ${d.figures.length} figures -> ${abs}/document.md`);
  return abs;
}

// The folder's entries, none when it isn't there.
const listDir = async (path: string) => { try { return await tb.fs.list(path); } catch { return []; } };

// A conversion's title: the top heading on its first page (a paper's title is its largest text; a
// licence note can come first, smaller), else its folder's name.
function titleOf(md: string, stem: string): string {
  const page = md.slice(Math.max(0, md.search(/^--- Page 1 ---$/m))).split(/^--- Page 2 ---$/m)[0];
  for (const level of ["#", "##", "###"]) {
    const h = page.match(new RegExp(`^${level} (.+)$`, "m"));
    if (h) return unstyled(h[1]).replace(/\s+/g, " ").trim().slice(0, 100);
  }
  return stem;
}

// A saved conversion, as the picker lists it; mtime is the document's write time, the conversion's.
interface Saved { stem: string; title: string; mtime: number }

// The saved conversions, newest first by when their document was written: a re-conversion overwrites
// its files, which leaves the folder's own mtime where the first one put it.
async function listConversions(): Promise<Saved[]> {
  const dirs = (await listDir("converted")).filter(e => e.dir);
  const stamped = await Promise.all(dirs.map(async d => {
    const doc = (await listDir(folderOf(d.name))).find(e => e.name === "document.md");
    if (!doc) return null;
    const md = await tb.fs.read(`${folderOf(d.name)}/document.md`);
    return { stem: d.name, title: titleOf(md, d.name), mtime: doc.mtime };
  }));
  return stamped
    .filter((s): s is Saved => s !== null)
    .sort((a, b) => b.mtime - a.mtime);
}

// How long ago, the mirror's form: now, 5m, 3h, 2d, then "5 Jan" past a week.
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
function relTime(ms: number): string {
  const d = Math.max(0, Date.now() - ms);
  if (d < 60_000) return "now";
  if (d < 3_600_000) return `${Math.floor(d / 60_000)}m`;
  if (d < 86_400_000) return `${Math.floor(d / 3_600_000)}h`;
  if (d < 7 * 86_400_000) return `${Math.floor(d / 86_400_000)}d`;
  const date = new Date(ms);
  return `${date.getDate()} ${MONTHS[date.getMonth()]}`;
}

// The saved conversions as a search box: type to filter by title or stem, arrows and Enter (or a
// click) open one. The list is fixed-positioned, measured from the box: the bar clips its overflow
// for the scrollbar gutter, so an absolute list would scroll inside the bar instead of over the page.
function PrevPicker({ items, current, onOpen }: { items: Saved[]; current?: string; onOpen: (stem: string) => void }) {
  const [filter, setFilter] = useState("");
  const [open, setOpen] = useState(false);
  const [highlighted, setHighlighted] = useState(0);
  const [pos, setPos] = useState<{ top: number; left: number; width: number } | null>(null);
  const wrapRef = useRef<HTMLDivElement>(null);
  const inputRef = useRef<HTMLInputElement>(null);
  const listRef = useRef<HTMLDivElement>(null);

  const q = filter.trim().toLowerCase();
  const rows = q ? items.filter(s => s.title.toLowerCase().includes(q) || s.stem.toLowerCase().includes(q)) : items;

  const place = () => {
    const r = inputRef.current?.getBoundingClientRect();
    if (r) setPos({ top: r.bottom + 6, left: r.left, width: r.width });
  };
  const show = () => { place(); setOpen(true); };
  const close = () => { setOpen(false); setFilter(""); setHighlighted(0); };

  // Outside click closes, armed a tick late so the click that opened the list isn't the one that closes it.
  useEffect(() => {
    if (!open) return;
    const onClick = (e: MouseEvent) => { if (!wrapRef.current?.contains(e.target as Node)) close(); };
    const t = setTimeout(() => document.addEventListener("click", onClick));
    window.addEventListener("resize", place);
    return () => { clearTimeout(t); document.removeEventListener("click", onClick); window.removeEventListener("resize", place); };
  }, [open]);

  const move = (delta: number) => {
    if (!rows.length) return;
    const next = Math.max(0, Math.min(highlighted + delta, rows.length - 1));
    setHighlighted(next);
    setTimeout(() => (listRef.current?.children[next] as HTMLElement | undefined)?.scrollIntoView({ block: "nearest" }));
  };
  const activate = (i: number) => {
    const s = rows[i];
    if (!s) return;
    close();
    inputRef.current?.blur();
    onOpen(s.stem);
  };
  const onKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
    if (e.key === "Escape") { close(); inputRef.current?.blur(); }
    else if (e.key === "ArrowDown") { e.preventDefault(); open ? move(1) : show(); }
    else if (e.key === "ArrowUp") { e.preventDefault(); move(-1); }
    else if (e.key === "Enter") { e.preventDefault(); activate(highlighted); }
  };

  return (
    <div className="prev-picker" ref={wrapRef}>
      <input
        ref={inputRef}
        className="url-input prev-input"
        type="text"
        placeholder={`Search ${items.length} saved conversion${items.length === 1 ? "" : "s"}…`}
        aria-label="Search saved conversions"
        value={filter}
        onChange={(e) => { setFilter(e.target.value); setHighlighted(0); if (!open) show(); }}
        onFocus={show}
        onClick={() => { if (!open) show(); }}
        onKeyDown={onKey}
      />
      {filter && (
        <button className="prev-clear" type="button" aria-label="Clear" onClick={() => { setFilter(""); setHighlighted(0); inputRef.current?.focus(); }}>×</button>
      )}
      {open && pos && (
        <div className="prev-list" ref={listRef} style={pos}>
          {rows.length === 0
            ? <div className="prev-empty">No match.</div>
            : rows.map((s, i) => (
              <div
                key={s.stem}
                className={`prev-row ${i === highlighted ? "active" : ""} ${s.stem === current ? "current" : ""}`}
                onMouseEnter={() => setHighlighted(i)}
                onClick={() => activate(i)}
              >
                <span className="prev-dot" />
                <span className="prev-title">{s.title}</span>
                {s.title !== s.stem && <span className="prev-stem">{s.stem}</span>}
                <span className="prev-time">{relTime(s.mtime)}</span>
              </div>
            ))}
        </div>
      )}
    </div>
  );
}

// A conversion read back from its folder: the document and its figure files, with the facts the
// details line states read off the document's header and page markers.
async function loadConversion(stem: string): Promise<Conversion> {
  const dir = folderOf(stem);
  const md = await tb.fs.read(`${dir}/document.md`);
  const figures: FigureFile[] = [];
  for (const e of await listDir(`${dir}/figures`)) {
    if (e.dir || !/\.png$/i.test(e.name)) continue;
    figures.push({ file: `figures/${e.name}`, png: await tb.fs.readBytes(`${dir}/figures/${e.name}`) });
  }
  const body = Math.max(0, md.search(/^--- Page \d+ ---$/m));
  return {
    srcName: md.match(/^# Markdown of (.+)$/m)?.[1] ?? `${stem}.pdf`,
    source: md.match(/^Source: (.+)$/m)?.[1] ?? "",
    stem,
    pages: (md.match(/^--- Page \d+ ---$/gm) || []).length,
    figures,
    md,
    totalChars: md.length - body,
    isScan: md.includes("no text layer"),
    textWidth: Number(md.match(/text column is (\d+) pt wide/)?.[1] ?? 0),
    latex: latexCount(md),
    arxiv: md.match(/LaTeX from arXiv:([^\s,]+)/)?.[1] ?? "",
  };
}

// The same layout as the folder, zipped: what the result becomes where there is no folder.
function zipConversion(d: Conversion): Uint8Array {
  const files: Zippable = { "document.md": strToU8(d.md) };
  for (const f of d.figures) files[f.file] = [f.png, { level: 0 }]; // PNG is already deflated
  return zipSync(files);
}

function downloadZip(d: Conversion) {
  const blob = new Blob([zipConversion(d) as BlobPart], { type: "application/zip" });
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url;
  a.download = `${d.stem}.zip`;
  a.click();
  URL.revokeObjectURL(url);
}

// A PDF to convert, and where it came from (the URL, the path, or for a dropped file its name).
interface Src { name: string; bytes: Uint8Array; source: string }

// Fetched by the page, so the host has to allow browser downloads (arXiv and most CDNs do). An
// arXiv link of any kind (abs, pdf, html, or a bare arXiv:id) fetches the paper's PDF.
async function fetchPdfUrl(url: string): Promise<Src> {
  const id = arxivIdOf(url);
  if (id) url = `https://arxiv.org/pdf/${id}`;
  const seg = decodeURIComponent(new URL(url).pathname.split("/").filter(Boolean).pop() || "document");
  const name = /\.pdf$/i.test(seg) ? seg : `${seg}.pdf`;
  let r: Response;
  try {
    r = await fetch(url);
  } catch {
    throw new Error("That host doesn't allow browser downloads. Save the PDF and drop the file here instead.");
  }
  if (!r.ok) throw new Error(`The server answered HTTP ${r.status} for that URL.`);
  return { name, bytes: new Uint8Array(await r.arrayBuffer()), source: url };
}

async function readPdfFile(path: string): Promise<Src> {
  return { name: path.split(/[\\/]/).pop()!, bytes: await tb.fs.readBytes(path), source: path };
}

const withTrustHint = (e: any) => {
  const msg = String(e?.message || e);
  return /403|trust|forbidden/i.test(msg)
    ? "Not saved: this run isn't trusted. Restart as `npx typebulb typebulbs/u/samples/pdf-to-markdown.bulb.md --trust`, or `typebulb trust` it once."
    : msg;
};

// Six-page synthetic PDF for the headless self-test (typebulb send <file> selftest).
async function makeTestPdf(): Promise<Uint8Array> {
  const doc = await PDFDocument.create();
  const bold = await doc.embedFont(StandardFonts.HelveticaBold);
  const reg = await doc.embedFont(StandardFonts.Helvetica);
  const p1 = doc.addPage([612, 792]);
  p1.drawText("Quarterly Report", { x: 72, y: 730, size: 28, font: bold });
  // An author block centered on the body column, its affiliation marks raised and small.
  const body1 = "Revenue grew 14 percent over the prior quarter, driven by";
  const colR = 72 + reg.widthOfTextAtSize(body1, 12);
  const centeredRow = (parts: [string, number][], y: number) => {
    let x = (72 + colR - parts.reduce((s, [t, size]) => s + reg.widthOfTextAtSize(t, size), 0)) / 2;
    for (const [t, size] of parts) {
      p1.drawText(t, { x, y: size < 12 ? y + 4 : y, size, font: reg });
      x += reg.widthOfTextAtSize(t, size);
    }
  };
  centeredRow([["Ada Lovelace", 12], ["1,2", 8]], 704);
  centeredRow([["1", 8], ["Analytical Engine Society", 12]], 690);
  p1.drawText(body1, { x: 72, y: 664, size: 12, font: reg });
  p1.drawText("subscription renewals and two enterprise deals.", { x: 72, y: 648, size: 12, font: reg });
  p1.drawText("Key risks", { x: 72, y: 610, size: 18, font: bold });
  p1.drawText("• Churn in the SMB segment", { x: 72, y: 586, size: 12, font: reg });
  p1.drawText("• Currency exposure in Europe", { x: 72, y: 570, size: 12, font: reg });
  // A little line chart: axes plus a 24-segment polyline, so it reads as drawing, not a frame.
  p1.drawLine({ start: { x: 90, y: 400 }, end: { x: 90, y: 540 }, thickness: 1 });
  p1.drawLine({ start: { x: 90, y: 400 }, end: { x: 380, y: 400 }, thickness: 1 });
  for (let i = 0; i < 24; i++) {
    const x = 90 + i * 12, y = 420 + 50 * (1 + Math.sin(i / 3));
    p1.drawLine({ start: { x, y }, end: { x: x + 12, y: 420 + 50 * (1 + Math.sin((i + 1) / 3)) }, thickness: 2, color: rgb(0.2, 0.4, 0.8) });
  }
  p1.drawText("Figure 1: A blue wave", { x: 72, y: 356, size: 12, font: reg });
  // Margin decoration: a sideways stamp, and a page number.
  p1.drawText("arXiv:0000.00000v1 [cs.LG] 1 Jan 2000", { x: 40, y: 300, size: 10, font: reg, rotate: degrees(90) });
  p1.drawText("1", { x: 300, y: 40, size: 12, font: reg });
  const p2 = doc.addPage([612, 792]);
  // A heading wrapped at a hyphen, and a section title in body-sized capitals.
  p2.drawText("Length Generaliza-", { x: 72, y: 720, size: 22, font: bold });
  p2.drawText("tion Appendix", { x: 72, y: 694, size: 22, font: bold });
  p2.drawText("Detailed tables", { x: 72, y: 660, size: 12, font: bold });
  p2.drawText("follow in the attached spreadsheet.", { x: 72 + bold.widthOfTextAtSize("Detailed tables ", 12), y: 660, size: 12, font: reg });
  p2.drawText("1 INTRODUCTION", { x: 72, y: 630, size: 12, font: reg });
  p2.drawText("The appendix lists every table behind the report.", { x: 72, y: 610, size: 12, font: reg });
  // A framed prompt with ragged lines, and a monospace listing: both keep their lines as code.
  p2.drawRectangle({ x: 72, y: 500, width: 330, height: 60, borderWidth: 1, borderColor: rgb(0, 0, 0) });
  p2.drawText("<answer>{", { x: 80, y: 543, size: 12, font: reg });
  p2.drawText('"1": ["Kareem", "educated at", "UCLA"],', { x: 80, y: 527, size: 12, font: reg });
  p2.drawText("}</answer>", { x: 80, y: 511, size: 12, font: reg });
  const mono = await doc.embedFont(StandardFonts.Courier);
  p2.drawText("def f(x):", { x: 72, y: 450, size: 12, font: mono });
  p2.drawText("return x + 1", { x: 72 + mono.widthOfTextAtSize("    ", 12), y: 434, size: 12, font: mono });
  // A subscript set low, past the line clustering's reach: it belongs to the line above it.
  p2.drawText("Energy E", { x: 72, y: 400, size: 12, font: reg });
  p2.drawText("k", { x: 72 + reg.widthOfTextAtSize("Energy E", 12), y: 395, size: 7, font: reg });
  // A landscape page the LaTeX way: portrait with /Rotate 90, its text turned to read up the page,
  // and a chart tall in page space (so wide as read); the page number stays upright.
  const p3 = doc.addPage([612, 792]);
  p3.setRotation(degrees(90));
  p3.drawText("Sideways table", { x: 270, y: 100, size: 18, font: bold, rotate: degrees(90) });
  p3.drawText("Sideways row one", { x: 300, y: 100, size: 12, font: reg, rotate: degrees(90) });
  p3.drawText("Sideways row two", { x: 316, y: 100, size: 12, font: reg, rotate: degrees(90) });
  for (let i = 0; i < 24; i++) {
    const y = 140 + i * 12, x = 480 + 50 * (1 + Math.sin(i / 3));
    p3.drawLine({ start: { x, y }, end: { x: 480 + 50 * (1 + Math.sin((i + 1) / 3)), y: y + 12 }, thickness: 2, color: rgb(0.2, 0.4, 0.8) });
  }
  p3.drawText("3", { x: 300, y: 40, size: 12, font: reg });
  // A two-column page on the body column: a title across both columns, then five lines of prose in each.
  const p4 = doc.addPage([612, 792]);
  p4.drawText("Two Columns", { x: 152, y: 720, size: 22, font: bold });
  for (const [side, x] of [["Left", 72], ["Right", 257]] as [string, number][])
    ["one", "two", "three", "four", "five"].forEach((w, i) =>
      p4.drawText(`${side} column line ${w}`, { x, y: 680 - i * 16, size: 12, font: reg }));
  // A contents page: chapter lines with no leader, sections with one, a title leaving room for
  // two dots, one wrapped short of its number; then a page continuing the list mid-depth. The
  // numbers are bold and set well past the leaders (pdf.js otherwise runs a leader and its number
  // into one item), and inside the body column, so the layout measures the same as without them.
  const entry = (pg: PDFPage, x: number, y: number, title: string, page: string, lead: boolean | number) => {
    pg.drawText(title, { x, y, size: 12, font: reg });
    if (lead) {
      const start = x + reg.widthOfTextAtSize(title, 12) + 4;
      const dots = typeof lead === "number" ? lead : Math.floor((280 - start) / reg.widthOfTextAtSize(". ", 12));
      pg.drawText(". ".repeat(dots).trim(), { x: start, y, size: 12, font: reg });
    }
    if (page) pg.drawText(page, { x: 360, y, size: 12, font: bold });
  };
  const p5 = doc.addPage([612, 792]);
  p5.drawText("Contents", { x: 72, y: 720, size: 22, font: bold });
  entry(p5, 72, 680, "1 Overview", "4", false);
  entry(p5, 92, 660, "1.1 Scope", "5", true);
  entry(p5, 112, 640, "1.1.1 Terms", "5", true);
  entry(p5, 92, 620, "1.2 Room for two dots", "6", 2);
  entry(p5, 92, 600, "1.3 A title so long it wraps to the", "", false);
  entry(p5, 92, 584, "next line", "6", true);
  entry(p5, 72, 560, "2 Methods", "7", false);
  const p6 = doc.addPage([612, 792]);
  entry(p6, 92, 720, "2.1 Methods", "7", true);
  entry(p6, 112, 700, "2.1.1 Data", "7", true);
  entry(p6, 72, 680, "3 Results", "9", false);
  return await doc.save();
}

const App = () => {
  const [result, setResult] = useState<Conversion | null>(null);
  const [html, setHtml] = useState("");
  const [busy, setBusy] = useState(false);
  const [progress, setProgress] = useState("");
  const [error, setError] = useState("");
  const [saved, setSaved] = useState<{ dir?: string; error?: string } | null>(null);
  const [details, setDetails] = useState(false);
  const [dragging, setDragging] = useState(false);
  const [toast, setToast] = useState("");
  const [url, setUrl] = useState("");
  const [previous, setPrevious] = useState<Saved[]>([]);

  const fileInputRef = useRef<HTMLInputElement>(null);
  const resultRef = useRef<Conversion | null>(null);
  const savedRef = useRef<{ dir?: string; error?: string } | null>(null);
  const errorRef = useRef("");
  const runRef = useRef<((src: Src) => Promise<Conversion | null>) | null>(null);

  const flash = (m: string) => { setToast(m); window.setTimeout(() => setToast(""), 1800); };

  const fail = (e: any) => {
    console.error(e);
    errorRef.current = withTrustHint(e) || "Failed to process PDF.";
    setError(errorRef.current);
  };

  // A document is on its way: the busy pane shows, and nothing of the last one.
  const begin = () => {
    setBusy(true);
    setError("");
    setSaved(null);
    savedRef.current = null;
    setDetails(false);
    setResult(null);
  };

  const run = async (src: Src): Promise<Conversion | null> => {
    begin();
    try {
      const d = await processBytes(src, setProgress);
      setResult(d);
      resultRef.current = d;
      if (tb.mode === "local") {
        setProgress("Saving...");
        try {
          savedRef.current = { dir: await saveToProject(d) };
        } catch (e: any) {
          savedRef.current = { error: withTrustHint(e) };
        }
        setSaved(savedRef.current);
      }
      return d;
    } catch (e: any) {
      fail(e);
      return null;
    } finally {
      setBusy(false);
      setProgress("");
    }
  };
  runRef.current = run;

  // Opens a saved conversion in the preview, as if it had just been made.
  const openPrevious = async (stem: string) => {
    begin();
    setProgress("Opening...");
    try {
      const d = await loadConversion(stem);
      setResult(d);
      resultRef.current = d;
      savedRef.current = { dir: absPath(folderOf(stem)) };
      setSaved(savedRef.current);
    } catch (e: any) {
      fail(e);
    } finally {
      setBusy(false);
      setProgress("");
    }
  };

  // The saved conversions, re-read as the page moves between documents: a save adds one, and a
  // headless run may have while the page sat.
  useEffect(() => {
    if (tb.mode === "local") listConversions().then(setPrevious);
  }, [result, saved]);

  // The preview: the markdown rendered, its figure files served from memory, each sized to its
  // share of the text column on the page (a one-line equation reads at the text's size, not the
  // width of the pane). An equation with its LaTeX renders as KaTeX in the image's place, one
  // display line per $$ block; a line KaTeX refuses (an author's macro) keeps the image, and so
  // does every equation with Details on, where the crops are there to check against.
  useEffect(() => {
    if (!result) { setHtml(""); return; }
    const imgs = new Map(result.figures.map(f => [f.file, {
      url: URL.createObjectURL(new Blob([f.png as BlobPart], { type: "image/png" })),
      pct: result.textWidth ? Math.min(100, pngDim(f.png).w / RENDER_SCALE / result.textWidth * 100) : 100,
    }]));
    const tex = new Map<string, string>();
    const blocks = /^!\[[^\]]*\]\((figures\/[^)]+)\)\n\n((?:\$\$\n[\s\S]*?\n\$\$\n\n?)+)/gm;
    let m: RegExpExecArray | null;
    while ((m = blocks.exec(result.md))) {
      const lines = m[2].split(/^\$\$$/m).map(s => s.trim()).filter(Boolean);
      try { tex.set(m[1], lines.map(l => katex.renderToString(l, { displayMode: true, throwOnError: true })).join("")); } catch {}
    }
    // From the first page on: the header orients an agent reading the file, and Details has it here.
    // The $$ blocks go too: marked would set the TeX as prose.
    const body = result.md.slice(Math.max(0, result.md.search(/^--- Page \d+ ---$/m))).replace(/^\$\$\n[\s\S]*?\n\$\$\n/gm, "");
    const out = (marked.parse(body, { async: false }) as string)
      .replace(/<img src="(figures\/[^"]+)"([^>]*)>/g, (_, f, rest) => {
        if (!details && tex.has(f)) return tex.get(f)!;
        const i = imgs.get(f);
        return `<img style="width:${(i?.pct ?? 100).toFixed(1)}%" src="${i?.url || ""}"${rest}>`;
      })
      .replace(/<p>--- Page (\d+) ---<\/p>/g, '<p class="pagemark">Page $1</p>');
    setHtml(out);
    return () => { for (const i of imgs.values()) URL.revokeObjectURL(i.url); };
  }, [result, details]);

  const handleFile = async (f?: File | null) => {
    if (!f) return;
    if (!/\.pdf$/i.test(f.name) && f.type !== "application/pdf") {
      setError("That file doesn't look like a PDF.");
      return;
    }
    await run({ name: f.name, bytes: new Uint8Array(await f.arrayBuffer()), source: f.name });
  };

  const onUrl = async () => {
    const u = url.trim();
    if (!u) return;
    setBusy(true);
    setError("");
    setProgress("Fetching...");
    let src: Src;
    try {
      src = await fetchPdfUrl(u);
    } catch (e: any) {
      fail(e);
      setBusy(false);
      setProgress("");
      return;
    }
    await run(src);
  };

  const reset = () => { setResult(null); resultRef.current = null; setSaved(null); setDetails(false); setError(""); };

  // The site's link box takes arXiv links only: arXiv allows a browser download and most hosts
  // don't, and a box that fails on most links reads as broken. The CLI's box takes any link.
  const arxivOnly = tb.mode !== "local";
  const badLink = arxivOnly && url.trim() !== "" && !arxivIdOf(url);

  // The saved conversions as a search box: on the landing pane, and beside the open document to switch.
  const prevPicker = previous.length > 0 && <PrevPicker items={previous} current={result?.stem} onOpen={openPrevious} />;

  // Headless: {url} or {file} (a path inside the bulb's folder) converts and saves, replying with where;
  // selftest runs a synthetic PDF through. See notes.md.
  useEffect(() => {
    tb.onMessage(async (m: any) => {
      try {
        if (m && typeof m === "object" && (m.url || m.file)) {
          const src = m.url ? await fetchPdfUrl(m.url) : await readPdfFile(m.file);
          const d = await runRef.current!(src);
          if (!d) return { error: errorRef.current };
          return { ...savedRef.current, pages: d.pages, chars: d.totalChars, figures: d.figures.length, latex: d.latex, isScan: d.isScan };
        }
        if (m === "selftest") {
          const d = await runRef.current!({ name: "selftest.pdf", bytes: await makeTestPdf(), source: "selftest.pdf" });
          if (!d) return { error: "processing failed" };
          // The turned page's figure, read the page's way, is wider than tall (PNG header: width at 16, height at 20).
          const side = d.figures.find(f => f.file.includes("page-003-fig"));
          return {
            ...savedRef.current,
            pages: d.pages,
            mdChars: d.md.length,
            markers: (d.md.match(/^--- Page \d+ ---$/gm) || []).length,
            figures: d.figures.length,
            figRef: (d.md.match(/!\[[^\]]*\]\(figures\/[^)]+\.png\)/g) || []).join(" "),
            fig0Bytes: d.figures[0]?.png.length ?? 0,
            zipBytes: zipConversion(d).length,
            isScan: d.isScan,
            arxiv: d.arxiv,
            latex: d.latex,
            superscript: d.md.includes("Lovelace¹,²"),
            affiliationBreak: d.md.includes("Lovelace¹,²\\\n¹Analytical"),
            wrappedHeading: d.md.includes("# Length Generalization Appendix"),
            capsHeading: d.md.includes("## 1 INTRODUCTION"),
            stampDropped: !d.md.includes("arXiv:"),
            pageNumberDropped: !/^1$/m.test(d.md),
            bold: d.md.includes("**Detailed tables** follow"),
            fencedBox: d.md.includes("```\n<answer>{\n\"1\": [\"Kareem\""),
            monoListing: d.md.includes("def f(x):\n    return x + 1"),
            lowSubscript: d.md.includes("Energy Eₖ"),
            sideways: d.md.includes("## Sideways table\n\nSideways row one Sideways row two"),
            sidewaysFigWide: !!side && pngDim(side.png).w > pngDim(side.png).h,
            columnWidth: d.textWidth,
            // Raw HTML in the text renders as text, once escaped, in prose and in code alike.
            escapes: marked.parse("<b>\n\n`<c>`", { async: false }) as string,
            twoColumn: d.md.includes("# Two Columns\n\nLeft column line one Left column line two Left column line three Left column line four Left column line five\n\nRight column line one Right column line two"),
            contents: d.md.includes("# Contents\n\n- 1 Overview … 4\n  - 1.1 Scope … 5\n    - 1.1.1 Terms … 5\n  - 1.2 Room for two dots … 6\n  - 1.3 A title so long it wraps to the next line … 6\n- 2 Methods … 7\n"),
            contentsContinued: d.md.includes("- 2.1 Methods … 7\n    - 2.1.1 Data … 7\n- 3 Results … 9\n"),
            mdHead: d.md.slice(0, 900),
          };
        }
      } catch (e: any) {
        return { error: String(e?.message || e) };
      }
    });
  }, []);

  return (
    <div
      className={`app ${dragging ? "dragging" : ""}`}
      onDragEnter={(e) => { e.preventDefault(); setDragging(true); }}
      onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
      onDragLeave={(e) => {
        if (e.currentTarget.contains(e.relatedTarget as Node)) return;
        setDragging(false);
      }}
      onDrop={(e) => { e.preventDefault(); setDragging(false); handleFile(e.dataTransfer.files?.[0]); }}
    >
      {result && !busy && (
        <header className="bar">
          <div className="bar-actions">
            {tb.mode === "local" ? saved?.dir && (
              <button className="secondary-btn" onClick={() => { tb.copy(saved.dir!); flash("Path copied"); }}>
                Copy folder path
              </button>
            ) : (
              <button className="secondary-btn" onClick={() => downloadZip(result)}>Download zip</button>
            )}
            <button className={`secondary-btn ${details ? "active" : ""}`} onClick={() => setDetails(!details)}>
              Details
            </button>
            <button className="secondary-btn" onClick={reset}>Another PDF</button>
            {prevPicker}
          </div>
        </header>
      )}
      <input
        ref={fileInputRef}
        type="file"
        accept="application/pdf,.pdf"
        style={{ display: "none" }}
        onChange={(e) => {
          const f = e.target.files?.[0];
          if (f) handleFile(f);
          e.target.value = "";
        }}
      />

      {result && !busy && saved?.error && <div className="panel err">{saved.error}</div>}

      {result && !busy && details && (
        <div className="panel hint">
          <span>
            {result.pages} {result.pages === 1 ? "page" : "pages"} · {(result.totalChars / 1000).toFixed(1)}k characters of text ·{" "}
            {result.figures.filter(f => !isEquationFile(f)).length} figures · {result.figures.filter(isEquationFile).length} equations
            {result.latex > 0 && ` · ${result.latex} with LaTeX`}
            {result.isScan && <span className="scan-note"> · no text layer found (a scan): each page is a figure</span>}
          </span>
          {result.source && (
            <span>Source: {/^https?:/.test(result.source)
              ? <a href={result.source} target="_blank" rel="noreferrer">{result.source}</a>
              : result.source}</span>
          )}
          {saved?.dir && <span>Saved to {saved.dir}</span>}
        </div>
      )}

      {!result && !busy && !error && (
        <div className="state-pane">
          <div className="dropzone">
            <h1 className="splash-title">PDF To Markdown</h1>
            <div className="dropzone-actions">
              <button className="primary-btn" onClick={() => fileInputRef.current?.click()}>Choose or drop a PDF</button>
              <form className="url-form" onSubmit={(e) => { e.preventDefault(); onUrl(); }}>
                <input
                  className="url-input"
                  type="url"
                  placeholder={arxivOnly ? "or paste an arXiv link" : "or paste a link"}
                  value={url}
                  onChange={(e) => setUrl(e.target.value)}
                />
                <button className="secondary-btn" type="submit" disabled={!url.trim() || badLink}>Fetch</button>
              </form>
              {badLink && <p className="dropzone-note">Only arXiv links work here (arxiv.org/abs/… or /pdf/…). For another PDF, drop the file.</p>}
              {prevPicker}
            </div>
            <p className="dropzone-sub">
              Creates a markdown file with extracted images and LaTeX{tb.mode === "local" ? "" : ", downloadable as a zip. Everything runs in your browser." }
            </p>
            {arxivOnly && <p className="dropzone-tip">For local Claude/Codex integration, tell your agent: run "npx typebulb agent", then pull this bulb.</p>}
          </div>
        </div>
      )}

      {busy && (
        <div className="state-pane">
          <div className="dropzone">
            <p className="dropzone-title">{progress || "Working..."}</p>
          </div>
        </div>
      )}

      {error && !busy && (
        <div className="state-pane">
          <div className="dropzone error">
            <p className="dropzone-title">Conversion failed</p>
            <p className="dropzone-sub">{error}</p>
            <button className="secondary-btn retry" onClick={() => setError("")}>Try again</button>
          </div>
        </div>
      )}

      {result && !busy && <div className="mdview" dangerouslySetInnerHTML={{ __html: html }} />}

      {dragging && <div className="drag-overlay">Drop PDF to convert</div>}
      <div className={`toast ${toast ? "show" : ""}`}>{toast}</div>
    </div>
  );
};

const container = document.getElementById("root");
const root = createRoot(container!);
root.render(<App />);
```
**styles.css**

```css
:root {
  --bg: #fafafa;
  --pane-bg: #efeff1;
  --text: #1d1d1f;
  --text-muted: #6e6e73;
  --border: rgba(0, 0, 0, 0.1);
  --card-bg: #ffffff;
  --selection: rgba(0, 0, 0, 0.05);
  --accent: #1d1d1f;
  --ok: #1a7f37;
  --err: #d04545;
  --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.06);
  --shadow-md: 0 4px 16px rgba(0, 0, 0, 0.08);
  --shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.12);
}

html[data-theme="dark"] {
  --bg: #1c1c1c;
  --pane-bg: #1c1c1c;
  --text: #f5f5f7;
  --text-muted: #a1a1a6;
  --border: rgba(255, 255, 255, 0.1);
  --card-bg: #232323;
  --selection: rgba(255, 255, 255, 0.08);
  --accent: #f5f5f7;
  --ok: #4ac26b;
  --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.4);
  --shadow-md: 0 4px 16px rgba(0, 0, 0, 0.5);
  --shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.6);
}

* { box-sizing: border-box; }

html, body, #root {
  height: 100%;
  margin: 0;
  padding: 0;
}

body {
  background: var(--bg);
  color: var(--text);
  font-family: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

.app {
  display: flex;
  flex-direction: column;
  height: 100%;
  position: relative;
}

/* The bar, the panels, and the document share one column, so their left edges line up: same
   side padding, same centered 800px column, and the scrollbar gutter reserved on all of them. */
.bar, .panel, .mdview {
  padding-left: 2rem;
  padding-right: 2rem;
  overflow: auto;
  scrollbar-gutter: stable both-edges;
}

.bar > *, .panel > *, .mdview > * { max-width: 800px; margin-left: auto; margin-right: auto; }

.bar {
  padding-top: 0.875rem;
  padding-bottom: 0.875rem;
  flex-shrink: 0;
}

.bar-actions {
  display: flex;
  gap: 0.5rem;
  align-items: center;
  flex-wrap: wrap;
}

.primary-btn {
  background: var(--accent);
  color: var(--card-bg);
  border: 1px solid var(--accent);
  padding: 0.5rem 1.1rem;
  border-radius: 8px;
  font-weight: 600;
  font-size: 0.875rem;
  cursor: pointer;
  transition: opacity 0.2s;
  letter-spacing: -0.01em;
}

.primary-btn:hover { opacity: 0.9; }

.primary-btn:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

.secondary-btn {
  background: var(--card-bg);
  color: var(--text);
  border: 1px solid var(--border);
  padding: 0.5rem 1rem;
  border-radius: 8px;
  font-weight: 600;
  font-size: 0.875rem;
  cursor: pointer;
  transition: box-shadow 0.2s;
  letter-spacing: -0.01em;
}

.secondary-btn:hover { box-shadow: var(--shadow-sm); }

.secondary-btn.small { padding: 0.3rem 0.75rem; font-size: 0.8125rem; }

.secondary-btn.active { box-shadow: inset 0 0 0 1px var(--accent); }

.panel {
  display: flex;
  flex-direction: column;
  gap: 0.4rem;
  padding-top: 0.6rem;
  padding-bottom: 0.6rem;
  font-size: 0.875rem;
  flex-shrink: 0;
}

.hint { color: var(--text-muted); }
.panel a { color: inherit; }
.panel.err { color: var(--err); }

.scan-note { color: var(--err); }

.state-pane {
  flex: 1;
  background: var(--pane-bg);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 2rem;
}

.dropzone {
  background: var(--card-bg);
  border: 1px solid var(--border);
  padding: 3.75rem 3.25rem;
  max-width: 508px;
  text-align: center;
  box-shadow: var(--shadow-md);
}

.dropzone.error { border-color: var(--err); }

.splash-title {
  font-size: 1.5rem;
  font-weight: 700;
  margin: 0 0 1.25rem;
  letter-spacing: -0.02em;
}

.dropzone-title {
  font-size: 1.0625rem;
  font-weight: 600;
  margin: 0 0 0.5rem 0;
  color: var(--text);
}

.dropzone-sub {
  margin: 0;
  font-size: 0.875rem;
  line-height: 1.55;
  color: var(--text-muted);
}

.dropzone-actions {
  display: grid;
  gap: 0.6rem;
  justify-items: center;
  margin: 0.25rem 0 1rem;
}

.dropzone-actions .primary-btn {
  width: 100%;
  padding: 0.8rem 1.2rem;
  font-size: 1rem;
  border-radius: 10px;
}

.url-form {
  display: flex;
  gap: 0.4rem;
  width: 100%;
}

.url-input, .prev-input {
  flex: 1;
  min-width: 0;
  font: inherit;
  font-size: 0.875rem;
  padding: 0.5rem 0.75rem;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--card-bg);
  color: var(--text);
}

/* The saved-conversions search box and its list: the mirror's picker (filter box over a keyboard-
   navigable list) opening downward, sized to the box. The list is position: fixed, see PrevPicker. */
.prev-picker { position: relative; flex: 1 1 16rem; max-width: 28rem; }
.dropzone-actions .prev-picker { width: 100%; max-width: none; }
.prev-input { width: 100%; padding-right: 2rem; }
.prev-clear {
  position: absolute; right: 0.4rem; top: 50%; transform: translateY(-50%);
  appearance: none; border: none; background: transparent; cursor: pointer;
  color: var(--text-muted); font-size: 1.1rem; line-height: 1; padding: 0 0.3rem; border-radius: 4px;
}
.prev-clear:hover { color: var(--text); }
.prev-list {
  position: fixed; z-index: 10;
  display: flex; flex-direction: column; gap: 0.15rem;
  max-height: 420px; overflow-y: auto;
  padding: 0.35rem;
  text-align: left;
  background: var(--card-bg);
  border: 1px solid var(--border);
  border-radius: 10px;
  box-shadow: var(--shadow-lg);
}
.prev-row {
  display: flex; flex: none; gap: 0.6rem; align-items: baseline;
  padding: 0.4rem 0.6rem;
  border-radius: 6px;
  cursor: pointer;
  font-size: 0.875rem;
}
/* The keyboard cursor; hover moves it, so one tint serves both. */
.prev-row.active { background: color-mix(in srgb, var(--accent) 12%, transparent); }
/* The open document's row: an accent dot in a gutter reserved on every row, so titles align. */
.prev-dot { flex: none; align-self: center; width: 7px; height: 7px; border-radius: 50%; background: transparent; }
.prev-row.current .prev-dot { background: var(--accent); }
.prev-title { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.prev-stem { color: var(--text-muted); max-width: 45%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.prev-time { color: var(--text-muted); flex: none; min-width: 2.6rem; text-align: right; }
.prev-empty { padding: 0.8rem; color: var(--text-muted); text-align: center; }

.dropzone-note { margin: 0; font-size: 0.875rem; line-height: 1.55; }

/* The local-run tip under the card's blurb, set off by the theme's own tint rather than a colour of its own. */
.dropzone-tip {
  margin: 1rem 0 0;
  padding: 0.6rem 0.9rem;
  border-radius: 8px;
  background: var(--selection);
  font-size: 0.875rem;
  line-height: 1.55;
}

.secondary-btn.retry { margin-top: 1rem; }

.mdview {
  flex: 1;
  min-height: 0;
  padding-top: 1.5rem;
  padding-bottom: 4rem;
  font-size: 0.9375rem;
  line-height: 1.6;
}

.mdview h1 { font-size: 1.5rem; margin-top: 1.5rem; margin-bottom: 0.5rem; letter-spacing: -0.02em; }
.mdview h2 { font-size: 1.25rem; margin-top: 1.5rem; margin-bottom: 0.4rem; }
.mdview h3 { font-size: 1.0625rem; margin-top: 1.25rem; margin-bottom: 0.3rem; }
.mdview p { margin-top: 0; margin-bottom: 0.9rem; }
.mdview ul, .mdview ol { margin-top: 0; margin-bottom: 0.9rem; padding-left: 1.5rem; }

.mdview .pagemark {
  color: var(--text-muted);
  font-size: 0.875rem;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  margin-top: 2rem;
  padding-top: 0.75rem;
  border-top: 1px solid var(--border);
}

.mdview .pagemark:first-child { margin-top: 0; padding-top: 0; border-top: 0; }

.mdview img {
  display: block;
  max-width: 100%;
  height: auto;
  border: 1px solid var(--border);
  border-radius: 6px;
  margin: 0.25rem 0 0.5rem;
}

/* Wide math scrolls sideways; auto on one axis alone would put a vertical bar on every block, for
   the few pixels the line box overhangs. */
.mdview .katex-display {
  margin: 0.25rem 0 0.9rem;
  padding: 0.35rem 0;
  overflow: auto hidden;
}

.mdview table {
  display: block;
  overflow-x: auto;
  border-collapse: collapse;
  margin-bottom: 1rem;
  font-size: 0.875rem;
}

.mdview th, .mdview td {
  border: 1px solid var(--border);
  padding: 0.35rem 0.6rem;
  text-align: left;
  vertical-align: top;
}

.mdview th { background: var(--selection); }

.mdview code {
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: 0.875em;
}

.mdview :not(pre) > code {
  background: var(--selection);
  padding: 0.1em 0.35em;
  border-radius: 4px;
}

.mdview pre {
  background: var(--selection);
  padding: 0.75rem 1rem;
  border-radius: 8px;
  overflow-x: auto;
  margin: 0 auto 1rem;
}

.drag-overlay {
  position: absolute;
  inset: 0;
  background: rgba(29, 29, 31, 0.85);
  color: #ffffff;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 1.5rem;
  font-weight: 600;
  letter-spacing: -0.02em;
  pointer-events: none;
  z-index: 100;
  border: 3px dashed rgba(255, 255, 255, 0.6);
}

.toast {
  position: fixed;
  left: 50%;
  bottom: 22px;
  transform: translate(-50%, 12px);
  background: var(--text);
  color: var(--bg);
  padding: 8px 16px;
  border-radius: 999px;
  font-size: 0.8125rem;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.18s, transform 0.18s;
  z-index: 200;
}

.toast.show { opacity: 0.92; transform: translate(-50%, 0); }

@media (max-width: 800px) {
  .bar, .panel, .mdview { padding-left: 1rem; padding-right: 1rem; }
  .bar { padding-top: 0.75rem; padding-bottom: 0.75rem; }
  .mdview { padding-top: 1rem; padding-bottom: 3rem; }
}
```
**index.html**

```html
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/dist/katex.min.css">
<div id="root"></div>
```
**config.json**

```json
{
  "dependencies": {
    "react": "^19.2.3",
    "react-dom": "^19.2.3",
    "pdfjs-dist": "^5.4.530",
    "pdf-lib": "^1.17.1",
    "marked": "^15.0.0",
    "fflate": "^0.8.3",
    "katex": "0.16.47"
  },
  "description": "Convert a PDF into a markdown document, handling images and latex."
}
```
**notes.md**

```md
Agent recipe: convert a PDF headlessly, then read the result.

1. Run the bulb trusted (it writes the files): `npx typebulb typebulbs/u/samples/pdf-to-markdown.bulb.md --trust`, backgrounded.
2. Send it the PDF and wait for the reply:
   `echo '{"url":"https://arxiv.org/pdf/2603.09970"}' | typebulb send typebulbs/u/samples/pdf-to-markdown.bulb.md - --wait=300000`
   or `{"file":"x.pdf"}`: a file inside the bulb's folder (`typebulbs/u/samples/pdf-to-markdown/`), relative to it or absolute; nothing outside it can be read.
   The reply is `{"dir": "<folder>", "pages", "chars", "figures"}`; a long PDF takes a minute or two, so size `--wait` to it.
3. Read `<folder>/document.md`. Its figures are PNGs under `<folder>/figures/`, referenced where they appear; open one when a chart, diagram, or screenshot matters. An arXiv paper's display equations carry their LaTeX in `$$` blocks under the image, so those need no opening.

The URL is fetched by the page, so the host must allow browser downloads (arXiv and most CDNs do); for one that doesn't, download the file into the bulb's folder and send `file`. The result always lands in `typebulbs/u/samples/pdf-to-markdown/converted/<stem>/`. `typebulb send <bulb> selftest --wait=60000` runs a synthetic PDF through the pipeline and reports what it found.

When a page converts badly, the run log (`typebulb logs <bulb>`) says what the pipeline decided: `[body]` the body font size and the pages measuring their own, `[columns]` the two-column pages and the gutter, `[frame]` the pages read turned, `[eq]` each page's text cut as display equations, `[fig]` each ink region's fate (figure, equation, text, or stray) with its box, drawn-element count, drawing weight, text density, the share of its text that is flow text, whether a caption claimed it, and what it took in, then the rules cleared as page furniture, `[arxiv]` whether the paper's HTML rendering was found, and `[latex]` each numbered equation's agreement with its LaTeX, rejections named.
```

Markdown source · More bulbs by samples · Typebulb home